summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-23 21:01:54 +0300
committerPaul Buetow <paul@buetow.org>2026-05-23 21:01:54 +0300
commita43272007fa4f13a5161257bfe85649874724da2 (patch)
treec913b07d5e7431ba7c69f3f9341d0aabba018c30
parent174b313c5ea475ff0baadf409f226d3e7b65738a (diff)
test: add unit tests for variable persistence (Variables.Load/Save)
Add persistence_test.go with 12 tests covering: - Load from corrupt JSON file returns proper error - Load filters invalid variable names (empty, spaces, symbols) - Round-trip Save/Load with 9 diverse float64 values (zero, negative, large, small, scientific, pi, integer, decimal) - Save produces sorted, indented JSON format - Save/Load with 50 variables - Concurrent SetVariable/GetVariable/Count/ListVariables on same instance - Load from empty JSON array succeeds with empty result - Load from corrupt file preserves existing variables (error before lock) - Save with Inf values returns error (JSON cannot represent Inf) - Load with duplicate names — last value wins - Load from empty file returns error (invalid JSON)
-rw-r--r--internal/rpn/persistence_test.go362
1 files changed, 362 insertions, 0 deletions
diff --git a/internal/rpn/persistence_test.go b/internal/rpn/persistence_test.go
new file mode 100644
index 0000000..2b8b505
--- /dev/null
+++ b/internal/rpn/persistence_test.go
@@ -0,0 +1,362 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package rpn
+
+import (
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestVariablesSaveLoadCorruptFile(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "corrupt.json")
+
+ // Write invalid JSON
+ if err := os.WriteFile(tmpFile, []byte("{invalid json"), 0644); err != nil {
+ t.Fatalf("failed to create corrupt file: %v", err)
+ }
+
+ vars := NewVariables()
+ err := vars.Load(tmpFile)
+ if err == nil {
+ t.Error("Load from corrupt JSON file should return an error")
+ }
+ if !strings.Contains(err.Error(), "unmarshal") {
+ t.Errorf("error should mention unmarshal, got: %v", err)
+ }
+}
+
+func TestVariablesLoadFiltersInvalidVariableNames(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "vars.json")
+
+ // Write JSON with valid and invalid variable names
+ jsonData := `[
+ {"Name": "valid_name", "Value": 42},
+ {"Name": "alsoValid123", "Value": 99.5},
+ {"Name": "", "Value": 10},
+ {"Name": "has space", "Value": 20},
+ {"Name": "has@symbol", "Value": 30},
+ {"Name": "valid_too", "Value": -5}
+ ]`
+ if err := os.WriteFile(tmpFile, []byte(jsonData), 0644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+
+ vars := NewVariables()
+ if err := vars.Load(tmpFile); err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ // Valid variables should be loaded
+ if vars.Count() != 3 {
+ t.Errorf("should have 3 valid variables, got %d", vars.Count())
+ }
+
+ if val, exists := vars.GetVariable("valid_name"); !exists || val != 42 {
+ t.Errorf("valid_name = %v (exists=%v), want 42", val, exists)
+ }
+ if val, exists := vars.GetVariable("alsoValid123"); !exists || val != 99.5 {
+ t.Errorf("alsoValid123 = %v (exists=%v), want 99.5", val, exists)
+ }
+ if val, exists := vars.GetVariable("valid_too"); !exists || val != -5 {
+ t.Errorf("valid_too = %v (exists=%v), want -5", val, exists)
+ }
+
+ // Invalid names should not be loaded
+ if vars.HasVariable("") {
+ t.Error("empty variable name should not be loaded")
+ }
+ if vars.HasVariable("has space") {
+ t.Error("variable with space should not be loaded")
+ }
+ if vars.HasVariable("has@symbol") {
+ t.Error("variable with symbol should not be loaded")
+ }
+}
+
+func TestVariablesSaveLoadRoundTripDiverseValues(t *testing.T) {
+ tests := []struct {
+ name string
+ value float64
+ }{
+ {"zero", 0},
+ {"negative", -42.5},
+ {"positive", 42.5},
+ {"veryLarge", 1.7976931348623157e+308}, // near max float64
+ {"verySmall", 5e-324}, // near min float64
+ {"scientific", 1.23e-10},
+ {"pi", 3.141592653589793},
+ {"integer", 100},
+ {"decimal", 0.1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "vars.json")
+
+ // Create and save
+ vars := NewVariables()
+ err := vars.SetVariable("v", tt.value)
+ if err != nil {
+ t.Fatalf("SetVariable failed: %v", err)
+ }
+ if err := vars.Save(tmpFile); err != nil {
+ t.Fatalf("Save failed: %v", err)
+ }
+
+ // Load and verify
+ newVars := NewVariables()
+ if err := newVars.Load(tmpFile); err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ loadedVal, exists := newVars.GetVariable("v")
+ if !exists {
+ t.Fatal("variable 'v' not found after load")
+ }
+ if loadedVal != tt.value {
+ t.Errorf("value = %v, want %v", loadedVal, tt.value)
+ }
+ })
+ }
+}
+
+func TestVariablesSaveFileFormat(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "vars.json")
+
+ vars := NewVariables()
+ _ = vars.SetVariable("z", 3.0)
+ _ = vars.SetVariable("a", 1.0)
+
+ if err := vars.Save(tmpFile); err != nil {
+ t.Fatalf("Save failed: %v", err)
+ }
+
+ // Verify JSON structure
+ data, err := os.ReadFile(tmpFile)
+ if err != nil {
+ t.Fatalf("failed to read saved file: %v", err)
+ }
+
+ content := string(data)
+ // Variables should be sorted by name, so "a" appears before "z"
+ aIdx := strings.Index(content, `"a"`)
+ zIdx := strings.Index(content, `"z"`)
+ if aIdx >= zIdx {
+ t.Errorf("variables should be sorted by name in JSON, got: %s", content)
+ }
+ // Should use indented JSON (MarshalIndent)
+ if !strings.Contains(content, "\n ") {
+ t.Errorf("expected indented JSON, got: %s", content)
+ }
+}
+
+func TestVariablesSaveLoadMultipleVariables(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "vars.json")
+
+ // Create and save many variables
+ vars := NewVariables()
+ for i := 0; i < 50; i++ {
+ name := string(rune('a'+i%26)) + string(rune('a'+(i/26)%26))
+ _ = vars.SetVariable(name, float64(i)*1.5)
+ }
+
+ if err := vars.Save(tmpFile); err != nil {
+ t.Fatalf("Save failed: %v", err)
+ }
+
+ // Load and verify all
+ newVars := NewVariables()
+ if err := newVars.Load(tmpFile); err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ if newVars.Count() != 50 {
+ t.Errorf("Count = %d, want 50", newVars.Count())
+ }
+
+ for i := 0; i < 50; i++ {
+ name := string(rune('a'+i%26)) + string(rune('a'+(i/26)%26))
+ want := float64(i) * 1.5
+ got, exists := newVars.GetVariable(name)
+ if !exists {
+ t.Errorf("variable %q not found", name)
+ continue
+ }
+ if got != want {
+ t.Errorf("%s = %v, want %v", name, got, want)
+ }
+ }
+}
+
+func TestVariablesSaveLoadConcurrency(t *testing.T) {
+ vars := NewVariables()
+ for i := 0; i < 10; i++ {
+ _ = vars.SetVariable(string(rune('a'+i)), float64(i))
+ }
+
+ // Save to a stable file first
+ tmpFile := filepath.Join(t.TempDir(), "vars.json")
+ if err := vars.Save(tmpFile); err != nil {
+ t.Fatalf("initial Save failed: %v", err)
+ }
+
+ errChan := make(chan error, 10)
+
+ // Concurrent loads from the same file (reads are safe)
+ for i := 0; i < 10; i++ {
+ go func() {
+ v := NewVariables()
+ errChan <- v.Load(tmpFile)
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ if err := <-errChan; err != nil {
+ t.Errorf("concurrent load failed: %v", err)
+ }
+ }
+
+ // Test concurrent SetVariable/GetVariable on same instance
+ vars2 := NewVariables()
+ var wg sync.WaitGroup
+
+ // Concurrent writes
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ _ = vars2.SetVariable(string(rune('x'+id%26)), float64(id))
+ }(i)
+ }
+
+ // Concurrent reads
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ vars2.GetVariable(string(rune('x' + id%26)))
+ }(i)
+ }
+
+ // Concurrent count/list
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _ = vars2.Count()
+ _ = vars2.ListVariables()
+ }()
+ }
+
+ wg.Wait()
+
+ // Verify final state is consistent
+ if vars2.Count() > 26 {
+ t.Errorf("Count = %d, expected at most 26 (x0-x25)", vars2.Count())
+ }
+}
+
+func TestVariablesLoadEmptyJSON(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "empty.json")
+
+ // Write empty JSON array
+ if err := os.WriteFile(tmpFile, []byte("[]"), 0644); err != nil {
+ t.Fatalf("failed to create file: %v", err)
+ }
+
+ vars := NewVariables()
+ if err := vars.Load(tmpFile); err != nil {
+ t.Fatalf("Load of empty JSON array failed: %v", err)
+ }
+
+ if vars.Count() != 0 {
+ t.Errorf("Count = %d, want 0", vars.Count())
+ }
+}
+
+func TestVariablesLoadPreservesExistingAfterError(t *testing.T) {
+ vars := NewVariables()
+ if err := vars.SetVariable("existing", 42.0); err != nil {
+ t.Fatalf("SetVariable failed: %v", err)
+ }
+
+ // Load from corrupt file
+ tmpFile := filepath.Join(t.TempDir(), "corrupt.json")
+ if err := os.WriteFile(tmpFile, []byte("not json"), 0644); err != nil {
+ t.Fatalf("failed to create corrupt file: %v", err)
+ }
+
+ err := vars.Load(tmpFile)
+ if err == nil {
+ t.Fatal("expected error loading corrupt file")
+ }
+
+ // Load() calls loadVariables() before acquiring the write lock.
+ // When loadVariables() errors, Load() returns the error without
+ // modifying the map, so existing state is preserved.
+ val, exists := vars.GetVariable("existing")
+ if !exists || val != 42.0 {
+ t.Errorf("existing variable should be preserved after failed Load: got %v (exists=%v)", val, exists)
+ }
+}
+
+func TestVariablesSaveInfReturnsError(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "inf.json")
+
+ vars := NewVariables()
+ // Inf values cannot be marshaled to JSON, so Save should fail
+ if err := vars.SetVariable("inf", math.Inf(1)); err != nil {
+ t.Fatalf("SetVariable failed: %v", err)
+ }
+ err := vars.Save(tmpFile)
+ if err == nil {
+ t.Error("Save with Inf value should return an error (JSON cannot represent Inf)")
+ }
+ if !strings.Contains(err.Error(), "unsupported value") {
+ t.Errorf("error should mention unsupported value, got: %v", err)
+ }
+}
+
+func TestVariablesLoadDuplicateNames(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "dupes.json")
+
+ // JSON with duplicate names — last one wins in Go maps
+ jsonData := `[{"Name":"x","Value":1},{"Name":"x","Value":2}]`
+ if err := os.WriteFile(tmpFile, []byte(jsonData), 0644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ vars := NewVariables()
+ if err := vars.Load(tmpFile); err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ // Should have exactly 1 variable (x), with value 2 (last wins)
+ if vars.Count() != 1 {
+ t.Errorf("Count = %d, want 1", vars.Count())
+ }
+ val, exists := vars.GetVariable("x")
+ if !exists || val != 2 {
+ t.Errorf("x = %v (exists=%v), want 2 (last wins)", val, exists)
+ }
+}
+
+func TestVariablesLoadEmptyFile(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "empty.json")
+
+ // Write empty file (not even valid JSON)
+ if err := os.WriteFile(tmpFile, []byte{}, 0644); err != nil {
+ t.Fatalf("failed to create empty file: %v", err)
+ }
+
+ vars := NewVariables()
+ err := vars.Load(tmpFile)
+ if err == nil {
+ t.Error("Load from empty file should return an error (invalid JSON)")
+ }
+}