- Add RawRule struct with Port *uint16, Protocol, Class fields - Add LoadResult struct with FreqCfgs, UserRules, ConfigPath fields - Change Load() signature to return LoadResult instead of bare map - Add validateRules: checks protocol required, class required, valid protocols - Add convertRules: converts RawRule slices to classify.Rule slices - Add autoAssignFreq: FNV-32a deterministic Hz in [1200-2350] range - Add addAutoFreqEntries: creates FreqConfig for new class names, skips built-ins - Reorder ops: addAutoFreqEntries before merge so sounds overrides apply to user classes - Update main.go call site to use LoadResult.FreqCfgs - Update all 8 existing tests to use LoadResult return type - Add 13 new tests covering rule parsing, validation, auto-freq, and LoadResult
458 lines
14 KiB
Go
458 lines
14 KiB
Go
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 (WaveformCustom), 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)
|
|
}
|
|
if cfgs[classify.ClassICMP].WaveformType != synth.WaveformCustom {
|
|
t.Errorf("ICMP WaveformType: got %v, want WaveformCustom (0)", cfgs[classify.ClassICMP].WaveformType)
|
|
}
|
|
// 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) != 14 {
|
|
t.Errorf("result map size: got %d, want 14", len(cfgs))
|
|
}
|
|
// 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) != 14 {
|
|
t.Errorf("result map size: got %d, want 14 (BOGUS should not appear)", len(cfgs))
|
|
}
|
|
// 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) != 14 {
|
|
t.Errorf("result map size: got %d, want 14", len(cfgs))
|
|
}
|
|
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 [1200, 2350]
|
|
// 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 < 1200.0 || cfg.BaseHz > 2350.0 {
|
|
t.Errorf("GameServer BaseHz: got %v, want in [1200, 2350]", 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)
|
|
}
|
|
}
|
|
|
|
// TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443)
|
|
// -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the 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)
|
|
}
|
|
if result.FreqCfgs[classify.ClassHTTPS].BaseHz != 175.0 {
|
|
t.Errorf("HTTPS BaseHz: got %v, want 175.0 (default, not auto-assigned)", result.FreqCfgs[classify.ClassHTTPS].BaseHz)
|
|
}
|
|
}
|
|
|
|
// 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) != 14 {
|
|
t.Errorf("FreqCfgs len: got %d, want 14", len(result.FreqCfgs))
|
|
}
|
|
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, "")
|
|
}
|
|
}
|