From f30e749ffbfe78f5e41184dafe632c7c71fc044c Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 16:45:58 +0200 Subject: [PATCH] ingest: add integration smoke coverage --- cmd/fmrtx/main.go | 8 ++- cmd/fmrtx/main_test.go | 22 ++++++ internal/ingest/factory/factory_test.go | 15 ++++ internal/ingest/factory/ingest_smoke_test.go | 76 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 internal/ingest/factory/ingest_smoke_test.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 203b2e2..080a89c 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -7,6 +7,7 @@ import ( "log" "os" "os/signal" + "strings" "syscall" "time" @@ -176,7 +177,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a var streamSrc *audio.StreamSource var ingestRuntime *ingest.Runtime var ingress ctrlpkg.AudioIngress - if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { + if ingestEnabled(cfg.Ingest.Kind) { rate := ingestfactory.SampleRateForKind(cfg) bufferFrames := rate * 2 if bufferFrames <= 0 { @@ -241,6 +242,11 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Println("shutdown complete") } +func ingestEnabled(kind string) bool { + normalized := strings.ToLower(strings.TrimSpace(kind)) + return normalized != "" && normalized != "none" +} + func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, audioHTTP bool) cfgpkg.Config { if audioRate > 0 { cfg.Ingest.Stdin.SampleRateHz = audioRate diff --git a/cmd/fmrtx/main_test.go b/cmd/fmrtx/main_test.go index f8d7cbc..5200d2d 100644 --- a/cmd/fmrtx/main_test.go +++ b/cmd/fmrtx/main_test.go @@ -9,6 +9,28 @@ import ( "github.com/jan/fm-rds-tx/internal/platform" ) +func TestIngestEnabled(t *testing.T) { + tests := []struct { + name string + kind string + want bool + }{ + {name: "empty", kind: "", want: false}, + {name: "none", kind: "none", want: false}, + {name: "none uppercase and spaces", kind: " NONE ", want: false}, + {name: "stdin", kind: "stdin", want: true}, + {name: "http raw uppercase", kind: " HTTP-RAW ", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ingestEnabled(tc.kind); got != tc.want { + t.Fatalf("ingestEnabled(%q)=%v want %v", tc.kind, got, tc.want) + } + }) + } +} + func TestTxBridgeExportsQueueStats(t *testing.T) { cfg := cfgpkg.Default() driver := platform.NewSimulatedDriver(nil) diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index d443b2d..f75a5e1 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -34,6 +34,21 @@ func TestBuildSourceHTTPRawProvidesIngress(t *testing.T) { } } +func TestBuildSourceKindIsNormalized(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = " HTTP-RAW " + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil || ingress == nil { + t.Fatalf("expected source and ingress for normalized http-raw kind") + } + if got := src.Descriptor().Kind; got != "http-raw" { + t.Fatalf("source kind=%q want http-raw", got) + } +} + func TestBuildSourceStdin(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "stdin" diff --git a/internal/ingest/factory/ingest_smoke_test.go b/internal/ingest/factory/ingest_smoke_test.go new file mode 100644 index 0000000..1cecd3d --- /dev/null +++ b/internal/ingest/factory/ingest_smoke_test.go @@ -0,0 +1,76 @@ +package factory + +import ( + "context" + "testing" + "time" + + "github.com/jan/fm-rds-tx/internal/audio" + "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "http-raw" + cfg.Ingest.HTTPRaw.SampleRateHz = 44100 + cfg.Ingest.HTTPRaw.Channels = 2 + + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil || ingress == nil { + t.Fatalf("expected source and ingress for kind=http-raw") + } + + sink := audio.NewStreamSource(128, cfg.Ingest.HTTPRaw.SampleRateHz) + rt := ingest.NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("runtime start: %v", err) + } + defer rt.Stop() + + // Two stereo frames: L1,R1,L2,R2 (S16LE). + frames, err := ingress.WritePCM16([]byte{ + 0xE8, 0x03, 0x18, 0xFC, + 0xD0, 0x07, 0x30, 0xF8, + }) + if err != nil { + t.Fatalf("write pcm16: %v", err) + } + if frames != 2 { + t.Fatalf("frames=%d want 2", frames) + } + + waitForSinkFrames(t, sink, 2) + + stats := rt.Stats() + if stats.Active.Kind != "http-raw" { + t.Fatalf("active kind=%q want http-raw", stats.Active.Kind) + } + if stats.Source.ChunksIn != 1 { + t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn) + } + if stats.Source.SamplesIn != 4 { + t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn) + } + if stats.Runtime.State != "running" { + t.Fatalf("runtime state=%q want running", stats.Runtime.State) + } + if stats.Runtime.LastChunkAt.IsZero() { + t.Fatalf("runtime lastChunkAt should be set") + } +} + +func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { + t.Helper() + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + if sink.Available() >= minFrames { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames) +}