From 3d14359832bd265cae0af623b0c4523a96634aed Mon Sep 17 00:00:00 2001 From: William Dillon Date: Mon, 17 Aug 2026 09:49:50 -0400 Subject: [PATCH] working on valuemonitor --- main_test.go | 27 +++++++ settings.go | 78 ++++++++++++++++++--- settings_test.go | 166 ++++++++++++++++++++++++++++++++++++++++++-- valuemonitor.go | 81 +++++++++++++++++++++ valuemonitortest.go | 39 +++++++++++ 5 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 main_test.go create mode 100644 valuemonitor.go create mode 100644 valuemonitortest.go diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..5c32646 --- /dev/null +++ b/main_test.go @@ -0,0 +1,27 @@ +package settings + +import ( + "errors" + "os" + "testing" +) + +const ( + TheTestFilename = "settings_test.settings" +) + +func TestMain(m *testing.M) { + exitCode := func() int { + if err := os.Remove(TheTestFilename); err != nil && !errors.Is(err, os.ErrNotExist) { + panic("error cleaning up " + TheTestFilename + " from previous run: " + err.Error()) + } else if err := os.WriteFile(TheTestFilename, nil, 0664); err != nil { + panic("error creating empty " + TheTestFilename + ": " + err.Error()) + } + defer os.Remove(TheTestFilename) + + TheSettings = NewSettings(TheTestFilename, false) + + return m.Run() + }() + os.Exit(exitCode) +} diff --git a/settings.go b/settings.go index 9433661..5d215a9 100644 --- a/settings.go +++ b/settings.go @@ -1,8 +1,10 @@ package settings import ( + "bufio" "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -24,11 +26,67 @@ var ( ) type Settings struct { - mutex sync.RWMutex - contents map[string]string - filename string - modtime time.Time - LogUpdates atomic.Bool + mutex sync.RWMutex + context context.Context + contents map[string]string + monitorKeys map[string]chan<- string + filename string + modtime time.Time + LogUpdates atomic.Bool +} + +func insertOrTimeout(ctx context.Context, c chan<- string, s string) error { + select { + case <-ctx.Done(): + return ctx.Err() + case c <- s: + return nil + } +} + +func (s *Settings) RegisterKeyToMonitor(key string, updateOutputChannel chan<- string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + if s.monitorKeys == nil { + s.monitorKeys = make(map[string]chan<- string, 1) + } + if _, found := s.monitorKeys[key]; found { + return fmt.Errorf("key %q already in monitorKeys map", key) + } + if err := insertOrTimeout(s.context, updateOutputChannel, s.contents[key]); err != nil { + return fmt.Errorf("error inserting: %w", err) + } + s.monitorKeys[key] = updateOutputChannel + return nil +} + +func (s *Settings) Dump() ([]byte, error) { + var buffer bytes.Buffer + if err := s.DumpToWriter(&buffer); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (s *Settings) DumpToWriter(w io.Writer) error { + writer := bufio.NewWriter(w) + s.mutex.RLock() + defer s.mutex.RUnlock() + var index int + for k, v := range s.contents { + writer.WriteString(fmt.Sprintf("%s=%s", k, v)) + if index < len(s.contents)-1 { + writer.WriteByte('\n') + } + index++ + } + return writer.Flush() +} + +func (s *Settings) DumpToJson() ([]byte, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + return json.Marshal(s.contents) } func (s *Settings) GetFilename() string { @@ -68,6 +126,9 @@ func (s *Settings) lockedSetKeyValue(key, value string) bool { log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value) } s.contents[strings.Clone(key)] = strings.Clone(value) + if updateChannel, found := s.monitorKeys[key]; found { + insertOrTimeout(s.context, updateChannel, value) + } return true } @@ -132,10 +193,10 @@ func (s *Settings) Update() error { return s.lockedUpdate() } -func (s *Settings) maintenanceRoutine(ctx context.Context) { +func (s *Settings) maintenanceRoutine() { for { select { - case <-ctx.Done(): + case <-s.context.Done(): return case <-time.After(MaintenanceRoutinePace): err := s.Update() @@ -154,6 +215,7 @@ func NewSettings(filename string, logUpdates bool) *Settings { func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings { settings := &Settings{ + context: ctx, contents: make(map[string]string), filename: strings.Clone(filename), } @@ -161,7 +223,7 @@ func NewSettingsWithContext(ctx context.Context, filename string, logUpdates boo if err := settings.lockedUpdate(); err != nil && (!errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound) { log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err) } - go settings.maintenanceRoutine(ctx) + go settings.maintenanceRoutine() return settings } diff --git a/settings_test.go b/settings_test.go index 0247226..a60de4b 100644 --- a/settings_test.go +++ b/settings_test.go @@ -2,8 +2,11 @@ package settings import ( "bytes" + "crypto/rand" + "encoding/json" "errors" "fmt" + "io" "log" "maps" "os" @@ -12,10 +15,6 @@ import ( "time" ) -const ( - TheTestFilename = "settings_test.settings" -) - var ( AdditionalKeyValuePairs = map[string]string{ "D": "d", @@ -43,8 +42,11 @@ func TestSettings(t *testing.T) { t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err) } }() - settings := NewSettingsWithContext(t.Context(), TheTestFilename, false) - if want, got := TheTestFilename, settings.GetFilename(); want != got { + settings := TheSettings + err = settings.Update() + if err != nil { + t.Fatalf("error updating settings: %v\n", err) + } else if want, got := TheTestFilename, settings.GetFilename(); want != got { t.Fatalf("error: wanted '%s'; got '%s'\n", want, got) } else if got := settings.modtime; got.Equal(time.Time{}) { t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized") @@ -125,3 +127,155 @@ func TestSettings(t *testing.T) { t.Fatalf("error: wanted error indicating 'no such file or directory'; got %v\n", err) } } + +func TestDump(t *testing.T) { + dump, err := TheSettings.Dump() + if err != nil { + t.Fatalf("error dumping settings: %v\n", err) + } + scanner := NewScanner(bytes.NewReader(dump)) + var found int +loop: + for { + key, value, err := scanner.ReadNextLine() + if err != nil { + if !errors.Is(err, io.EOF) { + t.Fatalf("error reading next line: %v\n", err) + } + break loop + } + if want, got := TheSettings.GetKeyValue(key), value; want != got { + t.Fatalf("error: wanted key %q value %q; got %q\n", key, want, got) + } + found++ + } + if want, got := len(TheSettings.contents), found; want != got { + t.Fatalf("error: wanted %d entries; got %d\n", want, got) + } +} + +func TestDumpJson(t *testing.T) { + marshaled, err := TheSettings.DumpToJson() + if err != nil { + t.Fatalf("error dumping to json: %v\n", err) + } + var unmarshaled map[string]string + err = json.Unmarshal(marshaled, &unmarshaled) + if err != nil { + t.Fatalf("error unmarshaling json dump: %v\n", err) + } + if want, got := TheSettings.contents, unmarshaled; !maps.Equal(want, got) { + t.Fatalf("error: wanted %v; got %v\n", want, got) + } +} + +func TestSetKeyValue(t *testing.T) { + key, value := rand.Text(), rand.Text() + if !TheSettings.SetKeyValue(key, value) { + t.Fatalf("SetKeyValue didn't update?\n") + } + if want, got := value, TheSettings.GetKeyValue(key); want != got { + t.Fatalf("error: wanted %q; got %q\n", want, got) + } +} + +func TestParseBool(t *testing.T) { + TheTestItems := map[string]bool{ + "yes": true, + "on": true, + "true": true, + "True": true, + "TRUE": true, + "no": false, + "off": false, + "false": false, + } + for boolString, want := range TheTestItems { + got, err := ParseBool(boolString) + if err != nil { + t.Fatalf("error: %v\n", err) + } + if want != got { + t.Fatalf("error: wanted %s=%v; got %v\n", boolString, want, got) + } + } + TheBadTestItems := []string{ + "blue", + "cat", + "https://google.com", + } + for _, boolString := range TheBadTestItems { + got, err := ParseBool(boolString) + if err == nil { + t.Fatalf("error: successfully parsed %s as bool %v...\n", boolString, got) + } + } +} + +func TestGetStringOrDefault(t *testing.T) { + dump, err := TheSettings.Dump() + if err != nil { + t.Fatalf("error getting dump: %v\n", err) + } + scanner := NewScanner(bytes.NewReader(dump)) +loop: + for { + key, value, err := scanner.ReadNextLine() + if err != nil { + if !errors.Is(err, io.EOF) { + t.Fatalf("error reading from dump: %v\n", err) + } + break loop + } + if want, got := value, TheSettings.GetStringOrDefault(key, "SomeInvalidDefault"); want != got { + t.Fatalf("error: wanted key %q=%q; got %q\n", key, want, got) + } + } + badKey := "some new key" + badValue := "some invalid value" + if want, got := badValue, TheSettings.GetStringOrDefault(badKey, badValue); want != got { + t.Fatalf("error: wanted key %q=%q; got %q\n", badKey, want, got) + } +} + +func TestGetBoolOrDefault(t *testing.T) { + key, value := "some bool value", "on" + TheSettings.SetKeyValue(key, value) + if want, got := true, TheSettings.GetBoolOrDefault(key, false); want != got { + t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got) + } + value = "shark" + TheSettings.SetKeyValue(key, value) + if want, got := false, TheSettings.GetBoolOrDefault(key, false); want != got { + t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got) + } +} + +func TestGetDurationOrDefault(t *testing.T) { + key, value := "some duration value", "10s" + TheSettings.SetKeyValue(key, value) + if want, got := time.Second*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got { + t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got) + } + value = "loud" + TheSettings.SetKeyValue(key, value) + if want, got := time.Minute*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got { + t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got) + } +} + +func TestGetIntOrDefault(t *testing.T) { + key, value := "some int value", "10" + TheSettings.SetKeyValue(key, value) + if want, got := 10, TheSettings.GetIntOrDefault(key, 200); want != got { + t.Fatalf("error: wanted %q=%d; got %d\n", key, want, got) + } + value = "cat" + TheSettings.SetKeyValue(key, value) + if want, got := 200, TheSettings.GetIntOrDefault(key, 200); want != got { + t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got) + } + if want, got := 200, TheSettings.GetIntOrDefault("some fake bad key", 200); want != got { + t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got) + } +} diff --git a/valuemonitor.go b/valuemonitor.go new file mode 100644 index 0000000..9f3c66c --- /dev/null +++ b/valuemonitor.go @@ -0,0 +1,81 @@ +package settings + +import ( + "errors" + "fmt" + "log" + "sync" +) + +type ValueMonitorParseFunc[T any] func(string) (T, error) + +type ValueMonitor[T any] struct { + mutex sync.RWMutex + parent *Settings + updates <-chan string + key string + parserFunc ValueMonitorParseFunc[T] + value T +} + +func (v *ValueMonitor[T]) GetValue() T { + v.mutex.RLock() + defer v.mutex.RUnlock() + return v.value +} + +func (v *ValueMonitor[T]) maintenanceThread() { + ctx := v.parent.context + for { + select { + case <-ctx.Done(): + return + case s, ok := <-v.updates: + if !ok { + return + } + t, err := v.parserFunc(s) + if err != nil { + log.Printf("ValueMonitor error parsing key %q value %q from channel: %v\n", v.key, s, err) + continue + } + func() { + v.mutex.RLock() + defer v.mutex.RUnlock() + v.value = t + }() + } + } +} + +func NewValueMonitor[T any](settings *Settings, key string, parserFunc ValueMonitorParseFunc[T]) (*ValueMonitor[T], error) { + monitorChannel := make(chan string, 1) + err := settings.RegisterKeyToMonitor(key, monitorChannel) + if err != nil { + close(monitorChannel) + return nil, fmt.Errorf("error registering ValueMonitor %w", err) + } + initialString, ok := <-monitorChannel + if !ok { + close(monitorChannel) + return nil, errors.New("channel closed before reading initial value") + } + parsed, err := parserFunc(initialString) + if err != nil { + close(monitorChannel) + return nil, fmt.Errorf("error parsing initial value from %q: %v", initialString, err) + } + go func() { + <-settings.context.Done() + close(monitorChannel) + }() + results := &ValueMonitor[T]{ + parent: settings, + updates: monitorChannel, + key: key, + parserFunc: parserFunc, + value: parsed, + } + go results.maintenanceThread() + return results, nil +} diff --git a/valuemonitortest.go b/valuemonitortest.go new file mode 100644 index 0000000..643c06d --- /dev/null +++ b/valuemonitortest.go @@ -0,0 +1,39 @@ +package settings + +import ( + "fmt" + "os" + "testing" +) + +func TestStringValueMonitor(t *testing.T) { + key, value := "my key to monitor", "first value" + monitor, err := NewValueMonitor(TheSettings, key, func(s string) (string, error) { return value, nil }) + if err != nil { + t.Fatalf("error opening new value monitor: %v\n", err) + } + if want, got := value, monitor.GetValue(); want != got { + t.Fatalf("error: wanted %q; got %q\n", want, got) + } + value = "second value" + TheSettings.SetKeyValue(key, value) + if want, got := value, monitor.GetValue(); want != got { + t.Fatalf("error: wanted %q; got %q\n", want, got) + } + value = "third value" + f, err := os.OpenFile(TheSettings.GetFilename(), os.O_APPEND|os.O_WRONLY, 0664) + if err != nil { + t.Fatalf("error opening file writer to append settings: %v\n", err) + } + _, err = f.WriteString(fmt.Sprintf("\n%s=%s\n", key, value)) + if err != nil { + t.Fatalf("error appending to file: %v\n", err) + } + err = TheSettings.Update() + if err != nil { + t.Fatalf("error updating the settings: %v\n", err) + } + if want, got := value, monitor.GetValue(); want != got { + t.Fatalf("error: wanted %q; got %q\n", want, got) + } +}