Просмотр исходного кода

feat: add dry-run pipeline and automated tests

tags/v0.3.0-pre
Jan Svabenik 1 месяц назад
Родитель
Сommit
623087a6b8
8 измененных файлов: 193 добавлений и 1 удалений
  1. +15
    -0
      cmd/fmrtx/main.go
  2. +6
    -0
      docs/README.md
  3. +37
    -0
      internal/config/config_test.go
  4. +37
    -0
      internal/control/control_test.go
  5. +65
    -0
      internal/dryrun/dryrun.go
  6. +31
    -0
      internal/dryrun/dryrun_test.go
  7. +1
    -0
      scripts/check.ps1
  8. +1
    -1
      scripts/run.ps1

+ 15
- 0
cmd/fmrtx/main.go Просмотреть файл

@@ -5,14 +5,18 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"


cfgpkg "github.com/jan/fm-rds-tx/internal/config" cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control" ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
) )


func main() { func main() {
configPath := flag.String("config", "", "path to JSON config") configPath := flag.String("config", "", "path to JSON config")
printConfig := flag.Bool("print-config", false, "print effective config and exit") printConfig := flag.Bool("print-config", false, "print effective config and exit")
dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
flag.Parse() flag.Parse()


cfg, err := cfgpkg.Load(*configPath) cfg, err := cfgpkg.Load(*configPath)
@@ -25,6 +29,17 @@ func main() {
return return
} }


if *dryRun {
frame := drypkg.Generate(cfg)
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
log.Fatalf("dry-run failed: %v", err)
}
if *dryOutput != "" && *dryOutput != "-" {
fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
}
return
}

srv := ctrlpkg.NewServer(cfg) srv := ctrlpkg.NewServer(cfg)
log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress)
log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))


+ 6
- 0
docs/README.md Просмотреть файл

@@ -6,6 +6,7 @@
- `go test ./...` - `go test ./...`
- `go run ./cmd/fmrtx -print-config` - `go run ./cmd/fmrtx -print-config`
- `go run ./cmd/fmrtx -config docs/config.sample.json` - `go run ./cmd/fmrtx -config docs/config.sample.json`
- `go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json`


### Internal DSP module ### Internal DSP module
- `cd internal` - `cd internal`
@@ -15,3 +16,8 @@
- `cd examples` - `cd examples`
- `go test ./...` - `go test ./...`
- `go run ./soapy_simulated` - `go run ./soapy_simulated`

## Dry run

The dry-run mode generates a synthetic, hardware-free frame summary based on the current config.
It is intended as a no-hardware smoke path for the CLI and config/control-adjacent logic.

+ 37
- 0
internal/config/config_test.go Просмотреть файл

@@ -0,0 +1,37 @@
package config

import (
"os"
"path/filepath"
"testing"
)

func TestDefaultValidate(t *testing.T) {
cfg := Default()
if err := cfg.Validate(); err != nil {
t.Fatalf("default config invalid: %v", err)
}
}

func TestLoadAndValidate(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.FM.FrequencyMHz != 99.9 {
t.Fatalf("unexpected frequency: %v", cfg.FM.FrequencyMHz)
}
}

func TestValidateRejectsBadFrequency(t *testing.T) {
cfg := Default()
cfg.FM.FrequencyMHz = 200
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error")
}
}

+ 37
- 0
internal/control/control_test.go Просмотреть файл

@@ -0,0 +1,37 @@
package control

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

func TestHealthz(t *testing.T) {
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
}

func TestStatus(t *testing.T) {
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["service"] != "fm-rds-tx" {
t.Fatalf("unexpected service: %v", body["service"])
}
}

+ 65
- 0
internal/dryrun/dryrun.go Просмотреть файл

@@ -0,0 +1,65 @@
package dryrun

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

type FrameSummary struct {
Mode string `json:"mode"`
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
RDSEnabled bool `json:"rdsEnabled"`
SampleRateHz int `json:"sampleRateHz"`
CompositeRate int `json:"compositeRateHz"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
OutputDrive float64 `json:"outputDrive"`
PreviewSamples []float64 `json:"previewSamples"`
}

func Generate(cfg cfgpkg.Config) FrameSummary {
preview := make([]float64, 16)
base := cfg.Audio.Gain * cfg.FM.OutputDrive
for i := range preview {
sign := 1.0
if i%2 == 1 {
sign = -1.0
}
preview[i] = sign * base * float64(i+1) / 16.0
}
return FrameSummary{
Mode: "dry-run",
FrequencyMHz: cfg.FM.FrequencyMHz,
StereoEnabled: cfg.FM.StereoEnabled,
RDSEnabled: cfg.RDS.Enabled,
SampleRateHz: cfg.Audio.SampleRate,
CompositeRate: cfg.FM.CompositeRateHz,
PilotLevel: cfg.FM.PilotLevel,
RDSInjection: cfg.FM.RDSInjection,
OutputDrive: cfg.FM.OutputDrive,
PreviewSamples: preview,
}
}

func WriteJSON(path string, frame FrameSummary) error {
data, err := json.MarshalIndent(frame, "", " ")
if err != nil {
return fmt.Errorf("marshal dry-run frame: %w", err)
}
if path == "" || path == "-" {
_, err = os.Stdout.Write(append(data, '\n'))
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write dry-run frame: %w", err)
}
return nil
}

+ 31
- 0
internal/dryrun/dryrun_test.go Просмотреть файл

@@ -0,0 +1,31 @@
package dryrun

import (
"os"
"path/filepath"
"testing"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

func TestGenerate(t *testing.T) {
cfg := cfgpkg.Default()
frame := Generate(cfg)
if frame.Mode != "dry-run" {
t.Fatalf("unexpected mode: %s", frame.Mode)
}
if len(frame.PreviewSamples) != 16 {
t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples))
}
}

func TestWriteJSONFile(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "frame.json")
if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil {
t.Fatalf("WriteJSON failed: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("expected output file: %v", err)
}
}

+ 1
- 0
scripts/check.ps1 Просмотреть файл

@@ -1,5 +1,6 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
go test ./... go test ./...
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
Push-Location internal Push-Location internal
go test ./... go test ./...
Pop-Location Pop-Location


+ 1
- 1
scripts/run.ps1 Просмотреть файл

@@ -1,2 +1,2 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
go run ./cmd/fmrtx -config docs/config.sample.json
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json

Загрузка…
Отмена
Сохранить