package config_test import ( "os" "strings" "testing" "github.com/netsynth/netsynth/classify" "github.com/netsynth/netsynth/config" "github.com/netsynth/netsynth/synth" ) // writeTOML creates a temp TOML file with the given content and returns its path. func writeTOML(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "*.toml") if err != nil { t.Fatalf("CreateTemp: %v", err) } if _, err := f.WriteString(content); err != nil { t.Fatalf("WriteString: %v", err) } if err := f.Close(); err != nil { t.Fatalf("Close: %v", err) } return f.Name() } // TestLoadPartialOverrideFrequency: setting only frequency for ICMP overrides BaseHz, // leaves WaveformType unchanged (preserves default), and leaves other classes unchanged. func TestLoadPartialOverrideFrequency(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].BaseHz != 100.0 { t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz) } // WaveformType should remain unchanged from the default (Phase 9: WaveformTriangle for ICMP) wantWaveform := synth.ClassFreqConfigs[classify.ClassICMP].WaveformType if cfgs[classify.ClassICMP].WaveformType != wantWaveform { t.Errorf("ICMP WaveformType: got %v, want %v (default, unchanged)", cfgs[classify.ClassICMP].WaveformType, wantWaveform) } // DNS should be unchanged want := synth.ClassFreqConfigs[classify.ClassDNS].BaseHz if cfgs[classify.ClassDNS].BaseHz != want { t.Errorf("DNS BaseHz: got %v, want %v (default)", cfgs[classify.ClassDNS].BaseHz, want) } } // TestLoadPartialOverrideWaveform: setting only waveform for ICMP changes WaveformType, // leaves BaseHz unchanged, and regenerates Harmonics. func TestLoadPartialOverrideWaveform(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nwaveform = \"square\"\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].WaveformType != synth.WaveformSquare { t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType) } // BaseHz should be unchanged (default is 65.0) if cfgs[classify.ClassICMP].BaseHz != 65.0 { t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz) } // Harmonics should be regenerated (non-empty) if len(cfgs[classify.ClassICMP].Harmonics) == 0 { t.Error("ICMP Harmonics: got empty slice, expected regenerated harmonics for WaveformSquare") } } // TestLoadBothOverrides: setting both frequency and waveform applies both. func TestLoadBothOverrides(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\nwaveform = \"square\"\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].BaseHz != 100.0 { t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz) } if cfgs[classify.ClassICMP].WaveformType != synth.WaveformSquare { t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType) } } // TestLoadUnknownKey: a typo'd field name produces an error naming the bad key. func TestLoadUnknownKey(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequncy = 440\n") _, err := config.Load(path) if err == nil { t.Fatal("expected error for unknown key 'frequncy', got nil") } if !strings.Contains(err.Error(), "frequncy") { t.Errorf("error should name the bad key 'frequncy', got: %v", err) } } // TestLoadNoConfig: Load("") in a directory with no netsynth.toml returns defaults with no error. func TestLoadNoConfig(t *testing.T) { // Chdir to a temp dir that has no netsynth.toml t.Chdir(t.TempDir()) result, err := config.Load("") if err != nil { t.Fatalf("Load with no config: %v", err) } cfgs := result.FreqCfgs if len(cfgs) != len(classify.AllClasses()) { t.Errorf("result map size: got %d, want %d", len(cfgs), len(classify.AllClasses())) } // ICMP should be at its default BaseHz (65.0) if cfgs[classify.ClassICMP].BaseHz != 65.0 { t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz) } } // TestLoadExplicitMissing: an explicit path that doesn't exist returns an error containing "not found". func TestLoadExplicitMissing(t *testing.T) { _, err := config.Load("/nonexistent/path/config.toml") if err == nil { t.Fatal("expected error for missing explicit file, got nil") } if !strings.Contains(err.Error(), "not found") { t.Errorf("error should contain 'not found', got: %v", err) } } // TestLoadUnknownClass: unknown class name produces no error (warning only), result has 14 entries. func TestLoadUnknownClass(t *testing.T) { path := writeTOML(t, "[sounds.BOGUS]\nfrequency = 100.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load with unknown class: %v", err) } cfgs := result.FreqCfgs if len(cfgs) != len(classify.AllClasses()) { t.Errorf("result map size: got %d, want %d (BOGUS should not appear)", len(cfgs), len(classify.AllClasses())) } // Confirm BOGUS is NOT in the map if _, ok := cfgs["BOGUS"]; ok { t.Error("BOGUS class should not be present in result map") } } // TestLoadInvalidWaveform: an invalid waveform string produces an error containing "invalid waveform". func TestLoadInvalidWaveform(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nwaveform = \"invalid\"\n") _, err := config.Load(path) if err == nil { t.Fatal("expected error for invalid waveform, got nil") } if !strings.Contains(err.Error(), "invalid waveform") { t.Errorf("error should contain 'invalid waveform', got: %v", err) } } // TestLoadAllDefaultsPresent: regardless of overrides, all 14 default classes are in the result map. func TestLoadAllDefaultsPresent(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 200.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } cfgs := result.FreqCfgs if len(cfgs) != len(classify.AllClasses()) { t.Errorf("result map size: got %d, want %d", len(cfgs), len(classify.AllClasses())) } for _, class := range classify.AllClasses() { if _, ok := cfgs[class]; !ok { t.Errorf("class %q missing from result map", class) } } } // --- New tests for Phase 7 Plan 01 --- // TestLoadCustomRules: TOML with [[rules]] block (port=8080, protocol="tcp", class="MyApp") // plus [sounds.MyApp] (frequency=300.0) parses successfully. func TestLoadCustomRules(t *testing.T) { toml := ` [[rules]] port = 8080 protocol = "tcp" class = "MyApp" [sounds.MyApp] frequency = 300.0 ` path := writeTOML(t, toml) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } if len(result.UserRules) != 1 { t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules)) } rule := result.UserRules[0] if rule.Protocol != "tcp" { t.Errorf("UserRules[0].Protocol: got %q, want %q", rule.Protocol, "tcp") } if rule.DstPort != 8080 { t.Errorf("UserRules[0].DstPort: got %d, want 8080", rule.DstPort) } if rule.Class != "MyApp" { t.Errorf("UserRules[0].Class: got %q, want %q", rule.Class, "MyApp") } if result.FreqCfgs["MyApp"].BaseHz != 300.0 { t.Errorf("FreqCfgs[MyApp].BaseHz: got %v, want 300.0", result.FreqCfgs["MyApp"].BaseHz) } } // TestLoadCustomRuleNoPort: TOML with [[rules]] (protocol="udp", class="AllUDP", no port field) // parses; UserRules[0].DstPort == 0. func TestLoadCustomRuleNoPort(t *testing.T) { toml := ` [[rules]] protocol = "udp" class = "AllUDP" ` path := writeTOML(t, toml) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } if len(result.UserRules) != 1 { t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules)) } if result.UserRules[0].DstPort != 0 { t.Errorf("UserRules[0].DstPort: got %d, want 0", result.UserRules[0].DstPort) } } // TestLoadCustomRuleMissingProtocol: [[rules]] with class="X" but no protocol -> error containing "protocol is required". func TestLoadCustomRuleMissingProtocol(t *testing.T) { toml := ` [[rules]] class = "X" ` path := writeTOML(t, toml) _, err := config.Load(path) if err == nil { t.Fatal("expected error for missing protocol, got nil") } if !strings.Contains(err.Error(), "protocol is required") { t.Errorf("error should contain 'protocol is required', got: %v", err) } } // TestLoadCustomRuleMissingClass: [[rules]] with protocol="tcp" but no class -> error containing "class is required". func TestLoadCustomRuleMissingClass(t *testing.T) { toml := ` [[rules]] protocol = "tcp" ` path := writeTOML(t, toml) _, err := config.Load(path) if err == nil { t.Fatal("expected error for missing class, got nil") } if !strings.Contains(err.Error(), "class is required") { t.Errorf("error should contain 'class is required', got: %v", err) } } // TestLoadCustomRuleInvalidProtocol: [[rules]] with protocol="ftp" -> error containing "invalid protocol". func TestLoadCustomRuleInvalidProtocol(t *testing.T) { toml := ` [[rules]] protocol = "ftp" class = "FTPTraffic" ` path := writeTOML(t, toml) _, err := config.Load(path) if err == nil { t.Fatal("expected error for invalid protocol 'ftp', got nil") } if !strings.Contains(err.Error(), "invalid protocol") { t.Errorf("error should contain 'invalid protocol', got: %v", err) } } // TestLoadCustomRuleUnknownField: [[rules]] with typo_field="bad" -> error containing "typo_field". func TestLoadCustomRuleUnknownField(t *testing.T) { toml := ` [[rules]] protocol = "tcp" class = "SomeClass" typo_field = "bad" ` path := writeTOML(t, toml) _, err := config.Load(path) if err == nil { t.Fatal("expected error for unknown field 'typo_field', got nil") } if !strings.Contains(err.Error(), "typo_field") { t.Errorf("error should contain 'typo_field', got: %v", err) } } // TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs; // caller can do append(result.UserRules, classify.DefaultRules...) to get user rules first. func TestUserRulesPrepend(t *testing.T) { toml := ` [[rules]] protocol = "tcp" port = 9000 class = "MyService" ` path := writeTOML(t, toml) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } combined := append(result.UserRules, classify.DefaultRules...) if len(combined) != len(classify.DefaultRules)+1 { t.Errorf("combined rules len: got %d, want %d", len(combined), len(classify.DefaultRules)+1) } // User rule should be first if combined[0].Class != "MyService" { t.Errorf("first rule should be user rule 'MyService', got %q", combined[0].Class) } } // TestAutoFreqAssignment: TOML with [[rules]] (class="GameServer", protocol="tcp") and // NO [sounds.GameServer] -> FreqCfgs contains "GameServer" entry with BaseHz in [2500, 4000] // and WaveformType == WaveformSine. func TestAutoFreqAssignment(t *testing.T) { toml := ` [[rules]] protocol = "tcp" class = "GameServer" ` path := writeTOML(t, toml) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } cfg, ok := result.FreqCfgs["GameServer"] if !ok { t.Fatal("FreqCfgs should contain 'GameServer' entry from auto-freq assignment") } if cfg.BaseHz < 2500.0 || cfg.BaseHz > 4000.0 { t.Errorf("GameServer BaseHz: got %v, want in [2500, 4000]", cfg.BaseHz) } if cfg.WaveformType != synth.WaveformSine { t.Errorf("GameServer WaveformType: got %v, want WaveformSine", cfg.WaveformType) } } // TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz. func TestAutoFreqDeterministic(t *testing.T) { toml := ` [[rules]] protocol = "tcp" class = "MyDeterministicClass" ` path := writeTOML(t, toml) result1, err := config.Load(path) if err != nil { t.Fatalf("Load (1): %v", err) } result2, err := config.Load(path) if err != nil { t.Fatalf("Load (2): %v", err) } hz1 := result1.FreqCfgs["MyDeterministicClass"].BaseHz hz2 := result2.FreqCfgs["MyDeterministicClass"].BaseHz if hz1 != hz2 { t.Errorf("auto-freq not deterministic: first=%v, second=%v", hz1, hz2) } } // --- PrintConfig tests --- // TestPrintConfigContainsAllClasses: defaults LoadResult produces output with all class names. func TestPrintConfigContainsAllClasses(t *testing.T) { t.Chdir(t.TempDir()) result, err := config.Load("") if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) for _, cls := range classify.AllClasses() { if !strings.Contains(output, string(cls)) { t.Errorf("PrintConfig output missing class %q", cls) } } } // TestPrintConfigSourcePath: LoadResult with ConfigPath set shows the path in header. func TestPrintConfigSourcePath(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) if !strings.Contains(output, "# Config source: "+path) { t.Errorf("expected output to contain '# Config source: %s', got:\n%s", path, output) } } // TestPrintConfigNoSourcePath: LoadResult with no ConfigPath shows "none" in header. func TestPrintConfigNoSourcePath(t *testing.T) { t.Chdir(t.TempDir()) result, err := config.Load("") if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) if !strings.Contains(output, "# Config source: none") { t.Errorf("expected output to contain '# Config source: none', got:\n%s", output) } } // TestPrintConfigContainsRules: LoadResult with user rules emits [[rules]] section. func TestPrintConfigContainsRules(t *testing.T) { tomlContent := ` [[rules]] port = 8080 protocol = "tcp" class = "MyApp" ` path := writeTOML(t, tomlContent) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) if !strings.Contains(output, "[[rules]]") { t.Errorf("expected output to contain '[[rules]]', got:\n%s", output) } if !strings.Contains(output, "port = 8080") { t.Errorf("expected output to contain 'port = 8080', got:\n%s", output) } if !strings.Contains(output, `protocol = "tcp"`) { t.Errorf("expected output to contain 'protocol = \"tcp\"', got:\n%s", output) } if !strings.Contains(output, `class = "MyApp"`) { t.Errorf("expected output to contain 'class = \"MyApp\"', got:\n%s", output) } } // TestPrintConfigRuleNoPort: rule with DstPort==0 omits port line in output. func TestPrintConfigRuleNoPort(t *testing.T) { tomlContent := ` [[rules]] protocol = "udp" class = "AllUDP" ` path := writeTOML(t, tomlContent) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) // Find the [[rules]] block and check no port line follows before the next blank line rulesIdx := strings.Index(output, "[[rules]]") if rulesIdx < 0 { t.Fatal("expected [[rules]] in output") } rulesSection := output[rulesIdx:] // Extract until the next blank line after [[rules]] lines := strings.Split(rulesSection, "\n") for _, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "port =") { t.Errorf("expected no 'port =' line for rule with DstPort=0, got line: %q", line) } if line == "" { break // end of this rule block } } } // TestPrintConfigDefaultAnnotation: default ICMP entry annotated as (default). func TestPrintConfigDefaultAnnotation(t *testing.T) { t.Chdir(t.TempDir()) result, err := config.Load("") if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) if !strings.Contains(output, "(default)") { t.Errorf("expected '(default)' annotation in output, got:\n%s", output) } // Specifically check ICMP line if !strings.Contains(output, "ICMP") { t.Errorf("expected ICMP in output") } } // TestPrintConfigOverrideAnnotation: ICMP with changed BaseHz annotated as (override). func TestPrintConfigOverrideAnnotation(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) // Find the ICMP comment line and check annotation lines := strings.Split(output, "\n") for _, line := range lines { if strings.Contains(line, "# ICMP") { if !strings.Contains(line, "(override)") { t.Errorf("expected ICMP comment to contain '(override)', got: %q", line) } return } } t.Error("did not find ICMP comment line in output") } // TestPrintConfigAutoAssignedAnnotation: user-defined class gets (auto-assigned) annotation. func TestPrintConfigAutoAssignedAnnotation(t *testing.T) { tomlContent := ` [[rules]] protocol = "tcp" class = "GameServer" ` path := writeTOML(t, tomlContent) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } output := config.PrintConfig(result) if !strings.Contains(output, "(auto-assigned)") { t.Errorf("expected '(auto-assigned)' annotation in output, got:\n%s", output) } if !strings.Contains(output, "GameServer") { t.Errorf("expected GameServer in output") } } // TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443) // -> FreqCfgs["HTTPS"].BaseHz == 150.0 (Phase 9 default), NOT an auto-assigned value. func TestAutoFreqSkipsBuiltins(t *testing.T) { toml := ` [[rules]] protocol = "tcp" port = 443 class = "HTTPS" ` path := writeTOML(t, toml) result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } wantHz := synth.ClassFreqConfigs[classify.ClassHTTPS].BaseHz if result.FreqCfgs[classify.ClassHTTPS].BaseHz != wantHz { t.Errorf("HTTPS BaseHz: got %v, want %.1f (default, not auto-assigned)", result.FreqCfgs[classify.ClassHTTPS].BaseHz, wantHz) } } // TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path; // Load("") with no file -> LoadResult.ConfigPath == "". func TestLoadResultConfigPath(t *testing.T) { // explicit path path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } if result.ConfigPath != path { t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, path) } // empty path with no config file t.Chdir(t.TempDir()) result2, err := config.Load("") if err != nil { t.Fatalf("Load with no config: %v", err) } if result2.ConfigPath != "" { t.Errorf("ConfigPath for no-config: got %q, want %q", result2.ConfigPath, "") } } // TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with // len(FreqCfgs)==14, len(UserRules)==0, ConfigPath=="". func TestLoadNoConfigReturnsLoadResult(t *testing.T) { t.Chdir(t.TempDir()) result, err := config.Load("") if err != nil { t.Fatalf("Load: %v", err) } if len(result.FreqCfgs) != len(classify.AllClasses()) { t.Errorf("FreqCfgs len: got %d, want %d", len(result.FreqCfgs), len(classify.AllClasses())) } if len(result.UserRules) != 0 { t.Errorf("UserRules len: got %d, want 0", len(result.UserRules)) } if result.ConfigPath != "" { t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, "") } }