package offline import ( "math" "os" "path/filepath" "strings" "testing" "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ) func TestGenerateFrame(t *testing.T) { g := NewGenerator(cfgpkg.Default()) frame := g.GenerateFrame(50 * time.Millisecond) if frame == nil { t.Fatal("expected frame") } if len(frame.Samples) == 0 { t.Fatal("expected samples") } } func TestGenerateFrameFMIQ(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = true g := NewGenerator(cfg) frame := g.GenerateFrame(10 * time.Millisecond) // With FM modulation, IQ samples should have magnitude ~1 for i := 100; i < len(frame.Samples) && i < 200; i++ { s := frame.Samples[i] mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) if math.Abs(mag-1.0) > 0.01 { t.Fatalf("sample %d: IQ magnitude=%.4f, expected ~1.0", i, mag) } } } func TestGenerateFrameCompositeOnly(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = false g := NewGenerator(cfg) frame := g.GenerateFrame(10 * time.Millisecond) // Without FM modulation, Q should be 0 for i := 0; i < len(frame.Samples) && i < 100; i++ { if frame.Samples[i].Q != 0 { t.Fatalf("sample %d: Q=%.6f, expected 0 in composite mode", i, frame.Samples[i].Q) } } } func TestStereoDisabledSuppressesPilotAndStereoDifference(t *testing.T) { cfgStereo := cfgpkg.Default() cfgStereo.FM.FMModulationEnabled = false cfgStereo.FM.StereoEnabled = true cfgStereo.Audio.ToneLeftHz = 1000 cfgStereo.Audio.ToneRightHz = 1600 cfgMono := cfgStereo cfgMono.FM.StereoEnabled = false stereoFrame := NewGenerator(cfgStereo).GenerateFrame(20 * time.Millisecond) monoFrame := NewGenerator(cfgMono).GenerateFrame(20 * time.Millisecond) if len(stereoFrame.Samples) != len(monoFrame.Samples) { t.Fatal("frame length mismatch") } var diffEnergy float64 for i := range stereoFrame.Samples { d := float64(stereoFrame.Samples[i].I - monoFrame.Samples[i].I) diffEnergy += d * d } if diffEnergy == 0 { t.Fatal("expected stereo-enabled and stereo-disabled composite output to differ") } } func TestWriteFile(t *testing.T) { cfg := cfgpkg.Default() out := filepath.Join(t.TempDir(), "test.iqf32") cfg.Backend.OutputPath = out g := NewGenerator(cfg) if err := g.WriteFile(out, 20*time.Millisecond); err != nil { t.Fatalf("WriteFile failed: %v", err) } info, err := os.Stat(out) if err != nil { t.Fatalf("expected output file: %v", err) } if info.Size() == 0 { t.Fatal("expected non-empty file") } } func TestSummaryUsesToneFallback(t *testing.T) { cfg := cfgpkg.Default() cfg.Audio.InputPath = "" g := NewGenerator(cfg) summary := g.Summary(10 * time.Millisecond) if !strings.Contains(summary, "source=tones") { t.Fatalf("unexpected summary: %s", summary) } } func TestSummaryUsesFallbackLabelOnBadWAV(t *testing.T) { cfg := cfgpkg.Default() cfg.Audio.InputPath = "missing.wav" g := NewGenerator(cfg) summary := g.Summary(10 * time.Millisecond) if !strings.Contains(summary, "source=tone-fallback") { t.Fatalf("unexpected summary: %s", summary) } } func TestSummaryContainsPreemph(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.PreEmphasisUS = 50 g := NewGenerator(cfg) summary := g.Summary(10 * time.Millisecond) if !strings.Contains(summary, "preemph=50µs") { t.Fatalf("unexpected summary: %s", summary) } } func TestSummaryContainsFMIQ(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = true g := NewGenerator(cfg) summary := g.Summary(10 * time.Millisecond) if !strings.Contains(summary, "FM-IQ") { t.Fatalf("unexpected summary: %s", summary) } } func TestLimiterPreventsClipping(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.LimiterEnabled = true cfg.FM.LimiterCeiling = 1.0 cfg.FM.FMModulationEnabled = false // raw composite to check levels cfg.Audio.ToneAmplitude = 0.9 // high amplitude to exercise limiter cfg.Audio.Gain = 2.0 cfg.FM.OutputDrive = 1.0 g := NewGenerator(cfg) frame := g.GenerateFrame(50 * time.Millisecond) for i, s := range frame.Samples { if math.Abs(float64(s.I)) > 1.01 { t.Fatalf("sample %d: composite=%.4f exceeds ceiling", i, s.I) } } } func TestParsePI(t *testing.T) { tests := []struct { name string in string want uint16 }{ {name: "plain hex", in: "1234", want: 0x1234}, {name: "0x prefix", in: "0xBEEF", want: 0xBEEF}, {name: "uppercase prefix", in: "0XCAFE", want: 0xCAFE}, {name: "whitespace", in: " 0x2345 ", want: 0x2345}, {name: "empty fallback", in: "", want: 0x1234}, {name: "invalid fallback", in: "nope", want: 0x1234}, } for _, tt := range tests { if got := parsePI(tt.in); got != tt.want { t.Fatalf("%s: got 0x%04X want 0x%04X", tt.name, got, tt.want) } } } func TestGeneratorUsesConfiguredPI(t *testing.T) { cfg := cfgpkg.Default() cfg.RDS.PI = "BEEF" if got := parsePI(cfg.RDS.PI); got != 0xBEEF { t.Fatalf("configured PI was not parsed as expected: got 0x%04X", got) } }