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:
+4
-2
@@ -6,13 +6,15 @@
|
||||
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
|
||||
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
|
||||
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
|
||||
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them."
|
||||
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
|
||||
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release."
|
||||
],
|
||||
"fr": [
|
||||
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
||||
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand l’antenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et l’animation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans l’autre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de l’antenne. Les deux lobes d’une antenne bidirectionnelle sont traités séparément, puisqu’ils ne franchissent pas le nord au même moment.",
|
||||
"Le widget rotor n’annonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop s’allumait et s’éteignait toute la soirée sur une antenne qui n’avait pas tourné. Ce qui distingue une rotation de la météo n’est pas l’amplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
|
||||
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence."
|
||||
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence.",
|
||||
"Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+187
-43
@@ -1,12 +1,15 @@
|
||||
package sat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -195,22 +198,44 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
_ = b.parse(shippedBirds)
|
||||
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
||||
}
|
||||
// New satellites reach an EXISTING station too.
|
||||
// New satellites AND corrections reach an existing station.
|
||||
//
|
||||
// The operator's copy is written once, on the first run, and was then
|
||||
// theirs for ever — which meant a release that added nine Tevel-2
|
||||
// satellites reached nobody who had already opened the tab. Merging on
|
||||
// each load fixes that without taking anything back: a satellite the
|
||||
// operator already has is left exactly as it is, edits included, and
|
||||
// only the ones they have never seen are added. Deleting a bird from the
|
||||
// file therefore brings it back, which is the price of the trade — and
|
||||
// the cheaper half of it, since an unwanted satellite is one row and a
|
||||
// missing one is a pass nobody can work.
|
||||
if n := b.addMissing(shippedBirds); n > 0 {
|
||||
// The operator's copy is written on the first run and was then theirs
|
||||
// for ever, which broke both ways: a release that added nine Tevel-2
|
||||
// satellites reached nobody who had opened the tab, and a frequency we
|
||||
// had shipped WRONG could never be mended — LilacSat-2 went out with an
|
||||
// APRS digipeater and no FM transponder, and the wrong data had become
|
||||
// the operator's own file.
|
||||
//
|
||||
// So the merge adds what is missing, replaces what they never touched,
|
||||
// and leaves alone what they edited. See mergeShipped for how the three
|
||||
// are told apart.
|
||||
added, updated, kept, replaced := b.mergeShipped(dir)
|
||||
if added > 0 || updated > 0 {
|
||||
// A one-time copy before the first run that can overwrite an entry
|
||||
// we have no baseline for. Cheap insurance on a file an operator may
|
||||
// have spent an evening correcting.
|
||||
if len(replaced) > 0 {
|
||||
if err := os.WriteFile(path+".bak", data, 0o644); err == nil {
|
||||
log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName)
|
||||
}
|
||||
}
|
||||
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
||||
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
||||
}
|
||||
log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated)
|
||||
}
|
||||
if len(replaced) > 0 {
|
||||
log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+
|
||||
"If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName)
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
// Named, not silent: an operator who corrected a frequency should be
|
||||
// able to see that OpsLog noticed and stood down.
|
||||
log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead",
|
||||
strings.Join(kept, ", "), BirdsName)
|
||||
}
|
||||
writeBaseline(dir)
|
||||
return b, nil
|
||||
case os.IsNotExist(err):
|
||||
if perr := b.parse(shippedBirds); perr != nil {
|
||||
@@ -218,6 +243,7 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
}
|
||||
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||
writeBaseline(dir)
|
||||
}
|
||||
return b, nil
|
||||
default:
|
||||
@@ -305,51 +331,169 @@ func (b *Birds) Len() int {
|
||||
return len(b.list)
|
||||
}
|
||||
|
||||
// addMissing appends the satellites in `shipped` that this list does not already
|
||||
// hold, and reports how many were added.
|
||||
// BaselineName records the shipped plan as it was last handed to this station.
|
||||
//
|
||||
// "Already hold" is by catalog number first and by the loose name second, so an
|
||||
// operator who renamed a bird, or who has it under the feed's spelling, does not
|
||||
// get a second copy of it. Nothing existing is touched: their frequencies, their
|
||||
// labels and their corrections all stand.
|
||||
func (b *Birds) addMissing(shipped []byte) int {
|
||||
// It exists so a CORRECTION can reach an operator who already has the file.
|
||||
// Without it the merge could only add satellites, never mend one: LilacSat-2
|
||||
// shipped with an APRS digipeater and no FM transponder, and every station that
|
||||
// had already opened the satellite tab was stuck with it for ever — the wrong
|
||||
// frequency was the operator's file now, and their file was sacred.
|
||||
const BaselineName = "satellites.shipped.json"
|
||||
|
||||
// mergeShipped brings the shipped plan into the operator's list.
|
||||
//
|
||||
// Three cases, and the middle one is the point:
|
||||
//
|
||||
// - A satellite they do not have is ADDED. That is how new birds arrive.
|
||||
// - A satellite they have, UNCHANGED from the plan they were given, is
|
||||
// REPLACED by the current one. They never edited it, so it is not theirs to
|
||||
// keep — it is our data, and ours was wrong.
|
||||
// - A satellite they have EDITED is left exactly as it is, and said so 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 the baseline, so the comparison is with the
|
||||
// plan THEY were given rather than with whatever ships today. Their edits
|
||||
// therefore survive every future release, not just the next one.
|
||||
func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) {
|
||||
var list []Bird
|
||||
if err := json.Unmarshal(shipped, &list); err != nil {
|
||||
return 0
|
||||
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
baseline := readBaseline(dir)
|
||||
|
||||
b.mu.Lock()
|
||||
have := make(map[int]bool, len(b.list))
|
||||
for _, x := range b.list {
|
||||
byNORAD := map[int]int{} // catalog number → index in b.list
|
||||
byName := map[string]int{}
|
||||
for i, x := range b.list {
|
||||
if x.NORAD != 0 {
|
||||
have[x.NORAD] = true
|
||||
byNORAD[x.NORAD] = i
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, cand := range list {
|
||||
if cand.NORAD != 0 && have[cand.NORAD] {
|
||||
continue
|
||||
}
|
||||
known := false
|
||||
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
|
||||
if k := loose(name); k != "" {
|
||||
if _, ok := b.byKey[k]; ok {
|
||||
known = true
|
||||
break
|
||||
for _, n := range append([]string{x.Name}, x.Aliases...) {
|
||||
if k := loose(n); k != "" {
|
||||
if _, seen := byName[k]; !seen {
|
||||
byName[k] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if known {
|
||||
}
|
||||
find := func(c Bird) int {
|
||||
if c.NORAD != 0 {
|
||||
if i, ok := byNORAD[c.NORAD]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for _, n := range append([]string{c.Name}, c.Aliases...) {
|
||||
if i, ok := byName[loose(n)]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
for _, cand := range list {
|
||||
i := find(cand)
|
||||
if i < 0 {
|
||||
b.list = append(b.list, cand)
|
||||
added++
|
||||
continue
|
||||
}
|
||||
b.list = append(b.list, cand)
|
||||
if cand.NORAD != 0 {
|
||||
have[cand.NORAD] = true
|
||||
if sameBird(b.list[i], cand) {
|
||||
continue // already current
|
||||
}
|
||||
if k := loose(cand.Name); k != "" {
|
||||
b.byKey[k] = len(b.list) - 1
|
||||
was, hadBaseline := baseline[birdKey(cand)]
|
||||
switch {
|
||||
case !hadBaseline:
|
||||
// FIRST run after baselines existed, and there is no record of what
|
||||
// this station was given — so an edit of theirs and a mistake of
|
||||
// ours are indistinguishable here.
|
||||
//
|
||||
// The shipped plan wins, ONCE, and the whole file is backed up
|
||||
// first. Standing down instead would have been the safe-looking
|
||||
// choice and the wrong one: the baseline written at the end of this
|
||||
// run would then record their entry as "edited" and freeze a
|
||||
// frequency we know to be wrong for the life of the install. A
|
||||
// backup and a log line are recoverable; that is not.
|
||||
replaced = append(replaced, b.list[i].Name)
|
||||
b.list[i] = cand
|
||||
updated++
|
||||
case sameBird(b.list[i], was):
|
||||
b.list[i] = cand
|
||||
updated++
|
||||
default:
|
||||
kept = append(kept, b.list[i].Name)
|
||||
}
|
||||
added++
|
||||
}
|
||||
b.reindexLocked()
|
||||
b.mu.Unlock()
|
||||
return added
|
||||
return added, updated, kept, replaced
|
||||
}
|
||||
|
||||
// birdKey identifies a satellite across versions: the catalog number when there
|
||||
// is one, the loose name otherwise.
|
||||
func birdKey(x Bird) string {
|
||||
if x.NORAD != 0 {
|
||||
return "n:" + strconv.Itoa(x.NORAD)
|
||||
}
|
||||
return "s:" + loose(x.Name)
|
||||
}
|
||||
|
||||
// sameBird compares two plans for one satellite by VALUE — the frequencies, the
|
||||
// modes, the tone, the labels. Field by field through JSON rather than one
|
||||
// comparison per field, so a transponder field added later cannot silently drop
|
||||
// out of the test and start reporting equal plans as different.
|
||||
func sameBird(a, c Bird) bool {
|
||||
ja, ea := json.Marshal(a)
|
||||
jc, ec := json.Marshal(c)
|
||||
if ea != nil || ec != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(ja, jc)
|
||||
}
|
||||
|
||||
// readBaseline loads the shipped plan this station was last given.
|
||||
func readBaseline(dir string) map[string]Bird {
|
||||
out := map[string]Bird{}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, BaselineName))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var list []Bird
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
return out
|
||||
}
|
||||
for _, x := range list {
|
||||
out[birdKey(x)] = x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBaseline records what was shipped, so the NEXT release can tell an
|
||||
// operator's correction from one of ours.
|
||||
func writeBaseline(dir string) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644)
|
||||
}
|
||||
|
||||
// reindexLocked rebuilds the name lookup after the list has changed.
|
||||
func (b *Birds) reindexLocked() {
|
||||
byKey := make(map[string]int, len(b.list)*3)
|
||||
put := func(name string, i int) {
|
||||
if k := loose(name); k != "" {
|
||||
if _, seen := byKey[k]; !seen {
|
||||
byKey[k] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, bird := range b.list {
|
||||
put(bird.Name, i)
|
||||
}
|
||||
for i, bird := range b.list {
|
||||
for _, a := range bird.Aliases {
|
||||
put(a, i)
|
||||
}
|
||||
}
|
||||
b.byKey = byKey
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user