package wshub import ( "os" "path/filepath" "testing" ) // newTestManager builds a hub + manager pair with no goroutines running. func newTestManager(t *testing.T) (*Hub, *SourceManager, string) { t.Helper() path := filepath.Join(t.TempDir(), "sources.json") h := NewHub() sm := NewSourceManager(h, path) h.SetSourceManager(sm) return h, sm, path } func TestSaveWritesSourcesAndCalibration(t *testing.T) { h, sm, path := newTestManager(t) // Register two sources without starting any UDP client. sm.mu.Lock() sm.sources["s1"] = &managedSource{id: "s1", label: "wave", addr: "127.0.0.1:44500"} sm.sources["s2"] = &managedSource{ id: "s2", label: "mc", addr: "127.0.0.1:44501", multicastGroup: "239.0.0.1", dataPort: 44502, } sm.mu.Unlock() if !h.cal.Set(CalConfig{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"}) { t.Fatal("calibration rejected") } if err := sm.Save(); err != nil { t.Fatalf("Save: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } srcs, cals, err := parseConfigFile(data) if err != nil { t.Fatalf("parseConfigFile: %v\n%s", err, data) } if len(srcs) != 2 { t.Fatalf("got %d sources, want 2\n%s", len(srcs), data) } // Save sorts by label so the file is byte-stable across runs. if srcs[0].Label != "mc" || srcs[1].Label != "wave" { t.Errorf("source order = %q,%q, want mc,wave", srcs[0].Label, srcs[1].Label) } if len(cals) != 1 || cals[0].Signal != "Adc" || cals[0].Scale != 0.5 { t.Fatalf("calibration round-trip failed: %+v\n%s", cals, data) } } func TestSaveWithoutFilePathFails(t *testing.T) { h := NewHub() sm := NewSourceManager(h, "") h.SetSourceManager(sm) if err := sm.Save(); err == nil { t.Error("Save() with no path = nil error, want error") } } func TestLoadSeedsCalibrationTable(t *testing.T) { h, sm, path := newTestManager(t) // No "addr" blocks: Load must not start any UDP client during the test. if err := os.WriteFile(path, []byte(`[ {"source":"wave","signal":"Adc","scale":0.25,"offset":2,"unit":"mV"}, {"source":"wave","signal":"Dac","scale":2} ]`), 0o644); err != nil { t.Fatal(err) } if err := sm.Load(path); err != nil { t.Fatalf("Load: %v", err) } got := h.cal.List() if len(got) != 2 { t.Fatalf("List() = %d entries, want 2", len(got)) } if got[0].Signal != "Adc" || got[0].Unit != "mV" || got[0].Offset != 2 { t.Errorf("Adc = %+v", got[0]) } if sm.Path() != path { t.Errorf("Path() = %q, want %q", sm.Path(), path) } } func TestReloadReplacesCalibrationAndKeepsLiveSources(t *testing.T) { h, sm, path := newTestManager(t) // A live source that the file does not mention must survive the reload. sm.mu.Lock() sm.sources["s1"] = &managedSource{id: "s1", label: "live", addr: "127.0.0.1:44999"} sm.mu.Unlock() // A stale calibration that the file does not mention must be dropped. h.cal.Set(CalConfig{Source: "stale", Signal: "Old", Scale: 9}) if err := os.WriteFile(path, []byte(`[ {"source":"wave","signal":"Adc","scale":0.5} ]`), 0o644); err != nil { t.Fatal(err) } if err := sm.Reload(); err != nil { t.Fatalf("Reload: %v", err) } got := h.cal.List() if len(got) != 1 || got[0].Source != "wave" { t.Fatalf("after Reload, calibration = %+v, want only wave/Adc", got) } sm.mu.RLock() _, alive := sm.sources["s1"] n := len(sm.sources) sm.mu.RUnlock() if !alive || n != 1 { t.Errorf("live source count = %d (s1 alive=%v), want 1 / true", n, alive) } } func TestReloadWithoutFilePathFails(t *testing.T) { h := NewHub() sm := NewSourceManager(h, "") h.SetSourceManager(sm) if err := sm.Reload(); err == nil { t.Error("Reload() with no path = nil error, want error") } }