Add a Scale option (1.0 native, 0.5 half, etc.) that downsamples the captured BGRA frame with bilinear interpolation before JPEG encoding. Config carries the scale factor; the GUI exposes a Scale dropdown (100%/75%/50%/25%) that applies live via SetConfig. Verified: OBS displays the image at the reduced resolution matching the selected scale, without restarting the stream.
304 lines
7.3 KiB
Go
304 lines
7.3 KiB
Go
package flinger
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"teleportfling/internal/capture"
|
|
)
|
|
|
|
// TestEnginePatternStartStop runs the engine with the synthetic pattern source
|
|
// and verifies it produces frames on the wire and stops cleanly.
|
|
func TestEnginePatternStartStop(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "pattern"
|
|
cfg.Port = 19756 // fixed high port for the test
|
|
|
|
eng, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
eng.Start()
|
|
defer eng.Stop()
|
|
|
|
// Connect a raw receiver and read until the engine reports frames sent.
|
|
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// The video loop sends ~30fps; wait for the counter to advance.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
_ = conn.SetReadDeadline(deadline)
|
|
|
|
// Drain whatever arrives while waiting for the frame counter to move.
|
|
go func() {
|
|
buf := make([]byte, 64*1024)
|
|
for {
|
|
if _, err := conn.Read(buf); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
for time.Now().Before(deadline) {
|
|
if eng.Status().Frames > 0 {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
if eng.Status().Frames == 0 {
|
|
t.Error("expected frames to be counted")
|
|
}
|
|
}
|
|
|
|
// TestEngineConfigDefaults verifies DefaultConfig is sane.
|
|
func TestEngineConfigDefaults(t *testing.T) {
|
|
c := DefaultConfig()
|
|
if c.Port != 9756 {
|
|
t.Errorf("default port = %d, want 9756", c.Port)
|
|
}
|
|
if c.Source != "screen" {
|
|
t.Errorf("default source = %q, want screen", c.Source)
|
|
}
|
|
if c.Quality < 1 || c.Quality > 100 {
|
|
t.Errorf("default quality %d out of range", c.Quality)
|
|
}
|
|
}
|
|
|
|
// TestNewRejectsBadSource ensures invalid sources fail fast.
|
|
func TestNewRejectsBadSource(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "bogus"
|
|
if _, err := New(cfg); err == nil {
|
|
t.Error("expected error for unknown source")
|
|
}
|
|
}
|
|
|
|
// TestSetErrStatus verifies runtime errors are exposed via Status and can be
|
|
// cleared.
|
|
func TestSetErrStatus(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "pattern"
|
|
cfg.Port = 19757
|
|
|
|
eng, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
if st := eng.Status(); st.Err != nil {
|
|
t.Fatalf("expected no error initially, got %v", st.Err)
|
|
}
|
|
|
|
sentinel := errors.New("test capture failure")
|
|
eng.setErr(sentinel)
|
|
if st := eng.Status(); st.Err != sentinel {
|
|
t.Errorf("expected sentinel error, got %v", st.Err)
|
|
}
|
|
|
|
eng.setErr(nil)
|
|
if st := eng.Status(); st.Err != nil {
|
|
t.Errorf("expected cleared error, got %v", st.Err)
|
|
}
|
|
}
|
|
|
|
// TestValidate rejects out-of-range values.
|
|
func TestValidate(t *testing.T) {
|
|
bad := []func(*Config){
|
|
func(c *Config) { c.Port = 0 },
|
|
func(c *Config) { c.Port = 70000 },
|
|
func(c *Config) { c.Quality = 0 },
|
|
func(c *Config) { c.Quality = 101 },
|
|
func(c *Config) { c.FPS = 0 },
|
|
func(c *Config) { c.StreamIndex = -1 },
|
|
}
|
|
for i, mutate := range bad {
|
|
c := DefaultConfig()
|
|
mutate(&c)
|
|
if err := c.Validate(); err == nil {
|
|
t.Errorf("case %d: expected validation error", i)
|
|
}
|
|
}
|
|
|
|
if err := DefaultConfig().Validate(); err != nil {
|
|
t.Errorf("default config should validate: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBitrateMeasurement verifies the engine reports a non-zero bitrate once
|
|
// it has been streaming for a bit.
|
|
func TestBitrateMeasurement(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "pattern"
|
|
cfg.Port = 19759
|
|
|
|
eng, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
eng.Start()
|
|
defer eng.Stop()
|
|
|
|
// Connect a receiver so packets actually flow, and wait for the bitrate
|
|
// window to produce a measurement.
|
|
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
go func() {
|
|
buf := make([]byte, 64*1024)
|
|
for {
|
|
if _, err := conn.Read(buf); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
deadline := time.Now().Add(4 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if eng.Status().Bitrate > 0 {
|
|
return
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
t.Error("bitrate stayed 0 after 4s of streaming")
|
|
}
|
|
|
|
// TestFormatBitrate checks the human-readable bitrate formatting.
|
|
func TestFormatBitrate(t *testing.T) {
|
|
cases := []struct {
|
|
bps int64
|
|
want string
|
|
}{
|
|
{500, "500 bps"},
|
|
{5_000, "5 kbps"},
|
|
{5_000_000, "5.0 Mbps"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := formatBitrate(c.bps); got != c.want {
|
|
t.Errorf("formatBitrate(%d) = %q, want %q", c.bps, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScaleBGRA verifies downsampling produces the expected dimensions and
|
|
// preserves the dominant colour of a solid frame.
|
|
func TestScaleBGRA(t *testing.T) {
|
|
src := &capture.VideoFrame{
|
|
Pix: make([]byte, 100*80*4),
|
|
Width: 100,
|
|
Height: 80,
|
|
Stride: 100 * 4,
|
|
}
|
|
// Fill with solid red (BGRA: B=0, G=0, R=255).
|
|
for i := 0; i+4 <= len(src.Pix); i += 4 {
|
|
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 0, 0, 255, 255
|
|
}
|
|
|
|
dst := &capture.VideoFrame{
|
|
Pix: make([]byte, 50*40*4),
|
|
Width: 50,
|
|
Height: 40,
|
|
Stride: 50 * 4,
|
|
}
|
|
scaleBGRA(src, dst)
|
|
|
|
if dst.Width != 50 || dst.Height != 40 {
|
|
t.Errorf("dst dims = %dx%d, want 50x40", dst.Width, dst.Height)
|
|
}
|
|
// Check a few pixels are solid red.
|
|
for _, idx := range []int{0, 4, 100, 200} {
|
|
if dst.Pix[idx] != 0 || dst.Pix[idx+1] != 0 || dst.Pix[idx+2] != 255 {
|
|
t.Errorf("pixel %d not red: B=%d G=%d R=%d", idx, dst.Pix[idx], dst.Pix[idx+1], dst.Pix[idx+2])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEngineScalePreservesNative verifies scaleFrame returns the original
|
|
// frame at scale 1.0.
|
|
func TestEngineScalePreservesNative(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "pattern"
|
|
cfg.Port = 19760
|
|
eng, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
frame := &capture.VideoFrame{Pix: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16}
|
|
if got := eng.scaleFrame(frame, 1.0); got != frame {
|
|
t.Error("scale 1.0 should return the original frame")
|
|
}
|
|
if got := eng.scaleFrame(frame, 0.5); got == frame {
|
|
t.Error("scale 0.5 should return a new frame")
|
|
}
|
|
}
|
|
|
|
func itoa(v int) string {
|
|
if v == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for v > 0 {
|
|
i--
|
|
buf[i] = byte('0' + v%10)
|
|
v /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// TestSetConfig verifies live config updates apply FPS/quality and reject
|
|
// invalid values, while preserving fields that can't change live.
|
|
func TestSetConfig(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Source = "pattern"
|
|
cfg.Port = 19758
|
|
|
|
eng, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
// Update FPS/quality live.
|
|
newCfg := DefaultConfig()
|
|
newCfg.Source = "screen" // must be ignored (requires restart)
|
|
newCfg.Port = 9999 // must be ignored
|
|
newCfg.Quality = 95
|
|
newCfg.FPS = 60
|
|
if err := eng.SetConfig(newCfg); err != nil {
|
|
t.Fatalf("SetConfig: %v", err)
|
|
}
|
|
|
|
got := eng.getCfg()
|
|
if got.Quality != 95 {
|
|
t.Errorf("quality = %d, want 95", got.Quality)
|
|
}
|
|
if got.FPS != 60 {
|
|
t.Errorf("fps = %d, want 60", got.FPS)
|
|
}
|
|
// Source/port preserved.
|
|
if got.Source != "pattern" {
|
|
t.Errorf("source = %q, want pattern (unchanged live)", got.Source)
|
|
}
|
|
if got.Port != 19758 {
|
|
t.Errorf("port = %d, want 19758 (unchanged live)", got.Port)
|
|
}
|
|
|
|
// Invalid config is rejected and current settings kept.
|
|
if err := eng.SetConfig(Config{Quality: 200}); err == nil {
|
|
t.Error("expected error for invalid quality")
|
|
}
|
|
got = eng.getCfg()
|
|
if got.Quality != 95 {
|
|
t.Errorf("quality changed after rejected update: %d", got.Quality)
|
|
}
|
|
}
|