spaxel/mothership/cmd/sim/main_test.go
jedarden 5ad6730524 fix(simulator): fix TestTwoRayModel wall reflection test case
The test was using a wall at x=10 from y=0 to y=5, which still creates
a valid reflection geometry for TX at (1,1) and RX at (3,3). The
reflection point calculation correctly finds that the reflected ray
would intersect the wall at y≈2.125, which is within the wall's bounds.

Fixed the test to use a wall that truly cannot create a valid reflection:
vertical wall at x=2 from y=10 to y=15. The reflection of TX (1,1) across
x=2 would be at (3,1). The line from (3,1) to RX (3,3) is vertical at x=3,
which never intersects the wall at x=2.

The simulator implements:
- Synthetic walkers (random walk, path-following, node-to-node)
- CSI generation with two-ray propagation model
- Wall attenuation (drywall: 3dB, brick/concrete: 10dB, glass: 2dB, metal: 20dB)
- Fresnel zone-based deltaRMS computation
- BLE simulation and CSV ground truth output

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:16:49 -04:00

1009 lines
28 KiB
Go

package main
import (
"encoding/csv"
"encoding/json"
"math"
"math/rand"
"os"
"path/filepath"
"strings"
"testing"
)
// TestGenerateCSIFrameHeader tests that generated frames have correct binary header format.
// The frame must match the ingestion layer layout (ingestion/frame.go):
//
// [0:6] node_mac, [6:12] peer_mac, [12:20] timestamp_us,
// [20] rssi, [21] noise_floor, [22] channel, [23] n_sub, [24:] payload
func TestGenerateCSIFrameHeader(t *testing.T) {
rng := rand.New(rand.NewSource(42))
tx := &VirtualNode{ID: 0, MAC: generateMAC(0), Position: Point{X: 0, Y: 0, Z: 2}}
rx := &VirtualNode{ID: 1, MAC: generateMAC(1), Position: Point{X: 5, Y: 0, Z: 2}}
walkers := []*Walker{{ID: 0, Position: Point{X: 2.5, Y: 0, Z: 1.7}}}
frame := generateCSIFrame(tx, rx, walkers, nil, 0, rng)
// Check minimum length
if len(frame) < headerSize {
t.Fatalf("Frame too short: %d bytes (minimum %d)", len(frame), headerSize)
}
// Check MAC addresses are present (not all zeros) at ingestion format offsets
allZero := true
for i := 0; i < 6; i++ {
if frame[i] != 0 {
allZero = false
break
}
}
if allZero {
t.Error("TX MAC (node_mac) is all zeros")
}
allZero = true
for i := 6; i < 12; i++ {
if frame[i] != 0 {
allZero = false
break
}
}
if allZero {
t.Error("RX MAC (peer_mac) is all zeros")
}
// Check subcarrier count at ingestion format offset [23]
nSubRead := frame[23]
if nSubRead != 64 {
t.Errorf("Wrong n_sub: %d (expected 64)", nSubRead)
}
// Check payload length matches n_sub
expectedLen := headerSize + int(nSubRead)*2
if len(frame) != expectedLen {
t.Errorf("Frame length mismatch: %d (expected %d)", len(frame), expectedLen)
}
}
// TestRSSIInRange tests that RSSI is within plausible range for given distance.
// At 2m from TX with wall_attenuation=0, RSSI should be roughly [-50, -70].
func TestRSSIInRange(t *testing.T) {
rng := rand.New(rand.NewSource(42))
tx := &VirtualNode{ID: 0, MAC: generateMAC(0), Position: Point{X: 0, Y: 0, Z: 2}}
rx := &VirtualNode{ID: 1, MAC: generateMAC(1), Position: Point{X: 5, Y: 0, Z: 2}}
walkers := []*Walker{{ID: 0, Position: Point{X: 2.5, Y: 0, Z: 1.7}}}
frame := generateCSIFrame(tx, rx, walkers, nil, 0, rng)
// RSSI is at ingestion format offset [20]
rssi := int8(frame[20])
// RSSI should be in [-90, -30] dBm for any reasonable link
if rssi < -90 || rssi > -30 {
t.Errorf("RSSI out of range: %d (expected [-90, -30])", rssi)
}
}
// TestIQClamping tests that generated I/Q values are clamped to int8 range [-127, 127]
func TestIQClamping(t *testing.T) {
rng := rand.New(rand.NewSource(42))
tx := &VirtualNode{ID: 0, Position: Point{X: 0, Y: 0, Z: 2}}
rx := &VirtualNode{ID: 1, Position: Point{X: 0.1, Y: 0, Z: 2}}
walkers := []*Walker{{ID: 0, Position: Point{X: 0.05, Y: 0, Z: 1.7}}}
frame := generateCSIFrame(tx, rx, walkers, nil, 0, rng)
for k := 0; k < 64; k++ {
offset := headerSize + k*2
i := int8(frame[offset])
q := int8(frame[offset+1])
if i < -127 || i > 127 {
t.Errorf("I value out of range at subcarrier %d: %d", k, i)
}
if q < -127 || q > 127 {
t.Errorf("Q value out of range at subcarrier %d: %d", k, q)
}
}
}
// TestHelloMessageFormat tests that hello message can be parsed correctly
func TestHelloMessageFormat(t *testing.T) {
hello := map[string]interface{}{
"type": "hello",
"mac": "AA:BB:CC:DD:EE:FF",
"firmware_version": "sim-1.0.0",
"capabilities": []string{"csi", "tx", "rx"},
"chip": "ESP32-S3",
"flash_mb": 16,
"uptime_ms": 1000,
"wifi_rssi": -45,
"ip": "127.0.0.1",
}
if hello["type"] != "hello" {
t.Error("Wrong message type")
}
if _, ok := hello["mac"].(string); !ok {
t.Error("MAC field missing or not string")
}
capabilities, ok := hello["capabilities"].([]string)
if !ok || len(capabilities) == 0 {
t.Error("Capabilities field missing or empty")
}
}
// TestVerifyBlobCount tests the verification logic
func TestVerifyBlobCount(t *testing.T) {
tests := []struct {
name string
blobCount int
walkerCount int
expectedPass bool
}{
{"Exact match", 1, 1, true},
{"Within tolerance +1", 2, 1, true},
{"Within tolerance -1", 0, 1, true},
{"Too many blobs", 3, 1, false},
{"Multiple walkers exact", 2, 2, true},
{"Multiple walkers within tolerance", 3, 2, true},
{"Multiple walkers too few", 0, 2, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tolerance := 1
minExpected := tt.walkerCount - tolerance
maxExpected := tt.walkerCount + tolerance
pass := tt.blobCount >= minExpected && tt.blobCount <= maxExpected
if pass != tt.expectedPass {
t.Errorf("verifyBlobs(%d walkers, %d blobs) = %v, expected %v",
tt.walkerCount, tt.blobCount, pass, tt.expectedPass)
}
})
}
}
// TestSeedReproducibility tests that the same seed produces identical results
func TestSeedReproducibility(t *testing.T) {
seed := int64(42)
rng1 := rand.New(rand.NewSource(seed))
rng2 := rand.New(rand.NewSource(seed))
tx := &VirtualNode{ID: 0, Position: Point{X: 0, Y: 0, Z: 2}}
rx := &VirtualNode{ID: 1, Position: Point{X: 5, Y: 0, Z: 2}}
walkers := []*Walker{{ID: 0, Position: Point{X: 2.5, Y: 0, Z: 1.7}}}
frame1 := generateCSIFrame(tx, rx, walkers, nil, 0, rng1)
frame2 := generateCSIFrame(tx, rx, walkers, nil, 0, rng2)
if len(frame1) != len(frame2) {
t.Fatalf("Frame lengths differ: %d vs %d", len(frame1), len(frame2))
}
for i := range frame1 {
if frame1[i] != frame2[i] {
t.Errorf("Byte %d differs: %d vs %d", i, frame1[i], frame2[i])
}
}
}
// TestSeedReproducibilityWalkerPaths tests that identical seeds produce identical walker trajectories
func TestSeedReproducibilityWalkerPaths(t *testing.T) {
seed := int64(42)
space := &Space{Width: 5, Depth: 5, Height: 2.5}
rng1 := rand.New(rand.NewSource(seed))
walkers1 := createWalkers(2, space, rng1)
rng2 := rand.New(rand.NewSource(seed))
walkers2 := createWalkers(2, space, rng2)
// Initial positions should match
for i := range walkers1 {
if walkers1[i].Position != walkers2[i].Position {
t.Errorf("Initial position mismatch for walker %d: %v vs %v",
i, walkers1[i].Position, walkers2[i].Position)
}
}
// Run 100 update steps and verify positions still match
for step := 0; step < 100; step++ {
rng1 = rand.New(rand.NewSource(seed))
rng2 = rand.New(rand.NewSource(seed))
// Reset walkers to get fresh RNG sequence
walkers1 = createWalkers(2, space, rng1)
walkers2 = createWalkers(2, space, rng2)
// Apply same number of updates
for s := 0; s <= step; s++ {
updateWalkers(walkers1, space, rng1)
updateWalkers(walkers2, space, rng2)
}
for i := range walkers1 {
if walkers1[i].Position != walkers2[i].Position {
t.Errorf("Position mismatch at step %d walker %d: %v vs %v",
step, i, walkers1[i].Position, walkers2[i].Position)
}
}
}
}
// TestParseSpace tests space dimension parsing
func TestParseSpace(t *testing.T) {
tests := []struct {
input string
wantWidth float64
wantDepth float64
wantHeight float64
wantErr bool
}{
{"5x5x2.5", 5, 5, 2.5, false},
{"10x8x3", 10, 8, 3, false},
{"6.5x4.2x2.8", 6.5, 4.2, 2.8, false},
{"invalid", 0, 0, 0, true},
{"5x5", 0, 0, 0, true},
{"5x5x2x3", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
space, err := parseSpace(tt.input)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if space.Width != tt.wantWidth {
t.Errorf("Width = %v, want %v", space.Width, tt.wantWidth)
}
if space.Depth != tt.wantDepth {
t.Errorf("Depth = %v, want %v", space.Depth, tt.wantDepth)
}
if space.Height != tt.wantHeight {
t.Errorf("Height = %v, want %v", space.Height, tt.wantHeight)
}
})
}
}
// TestMACGeneration tests MAC address generation
func TestMACGeneration(t *testing.T) {
tests := []struct {
id int
want string
}{
{0, "AA:BB:CC:00:00:00"},
{1, "AA:BB:CC:00:00:01"},
{255, "AA:BB:CC:00:00:FF"},
{256, "AA:BB:CC:00:01:00"},
{65535, "AA:BB:CC:00:FF:FF"},
{16777215, "AA:BB:CC:FF:FF:FF"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
mac := generateMAC(tt.id)
got := macToString(mac)
if got != tt.want {
t.Errorf("generateMAC(%d) = %s, want %s", tt.id, got, tt.want)
}
})
}
}
// TestComputeCSIForWalkers tests CSI computation with various walker configurations
func TestComputeCSIForWalkers(t *testing.T) {
tx := &VirtualNode{ID: 0, Position: Point{X: 0, Y: 0, Z: 2}}
rx := &VirtualNode{ID: 1, Position: Point{X: 5, Y: 0, Z: 2}}
tests := []struct {
name string
walkers []*Walker
minAmp float64
maxAmp float64
}{
{
name: "No walkers",
walkers: []*Walker{},
minAmp: 0,
maxAmp: 0.01,
},
{
name: "Walker at midpoint",
walkers: []*Walker{{
ID: 0,
Position: Point{X: 2.5, Y: 0, Z: 1.7},
}},
minAmp: 0.1,
maxAmp: 10,
},
{
name: "Walker far from link",
walkers: []*Walker{{
ID: 0,
Position: Point{X: 2.5, Y: 10, Z: 1.7},
}},
minAmp: 0,
maxAmp: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
amplitude, _ := computeCSIForWalkers(tx, rx, tt.walkers, nil)
if amplitude < tt.minAmp {
t.Errorf("Amplitude %v below minimum %v", amplitude, tt.minAmp)
}
if amplitude > tt.maxAmp {
t.Errorf("Amplitude %v above maximum %v", amplitude, tt.maxAmp)
}
})
}
}
// TestDistance tests distance calculation
func TestDistance(t *testing.T) {
tests := []struct {
a, b Point
want float64
}{
{Point{0, 0, 0}, Point{0, 0, 0}, 0},
{Point{0, 0, 0}, Point{1, 0, 0}, 1},
{Point{0, 0, 0}, Point{0, 1, 0}, 1},
{Point{0, 0, 0}, Point{0, 0, 1}, 1},
{Point{0, 0, 0}, Point{3, 4, 0}, 5},
{Point{1, 2, 3}, Point{4, 6, 3}, 5},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
got := distance(tt.a, tt.b)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("distance(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
// TestCSVOutput tests that CSV file has correct headers and ground truth data
func TestCSVOutput(t *testing.T) {
tmpDir := t.TempDir()
csvPath := filepath.Join(tmpDir, "test_output.csv")
csvWriter, err := NewCSVWriter(csvPath)
if err != nil {
t.Fatalf("Failed to create CSV writer: %v", err)
}
walkers := []*Walker{
{ID: 0, Position: Point{X: 2.5, Y: 1.5, Z: 1.7}, Velocity: Point{X: 0.5, Y: 0.3, Z: 0}},
}
nodes := []*VirtualNode{
{ID: 0, MAC: generateMAC(0), Position: Point{X: 0, Y: 0, Z: 2}},
{ID: 1, MAC: generateMAC(1), Position: Point{X: 5, Y: 0, Z: 2}},
}
csvWriter.WriteRow(walkers, nodes, nil)
csvWriter.Close() //nolint:errcheck
// Read back and verify
file, err := os.Open(csvPath)
if err != nil {
t.Fatalf("Failed to open CSV: %v", err)
}
defer func() { _ = file.Close() }()
reader := csv.NewReader(file)
records, err := reader.ReadAll()
if err != nil {
t.Fatalf("Failed to read CSV: %v", err)
}
// Check header
expectedHeaders := []string{"timestamp_ms", "walker_id", "x", "y", "z", "vx", "vy", "vz", "link_id", "delta_rms"}
if len(records) == 0 {
t.Fatal("CSV is empty")
}
for i, h := range expectedHeaders {
if i >= len(records[0]) {
t.Errorf("Missing header column %d: expected %s", i, h)
continue
}
if records[0][i] != h {
t.Errorf("Header[%d] = %q, want %q", i, records[0][i], h)
}
}
// Should have header + 1 position row + 1 link row (1 unique link pair for 2 nodes)
// With 2 nodes, there's 1 unique pair (0,1), so 1 position row + 1 deltaRMS row = 2 data rows
if len(records) < 3 {
t.Errorf("Expected at least 3 rows (header + position + deltaRMS), got %d", len(records))
}
// Verify position row has walker data
posRow := records[1]
if posRow[1] != "0" {
t.Errorf("Walker ID = %q, want %q", posRow[1], "0")
}
if posRow[2] != "2.500" {
t.Errorf("X = %q, want %q", posRow[2], "2.500")
}
// Verify deltaRMS row has link data
deltaRow := records[2]
if deltaRow[8] == "" {
t.Error("Expected link_id in deltaRMS row but got empty")
}
if deltaRow[9] == "" {
t.Error("Expected delta_rms value but got empty")
}
if !strings.Contains(deltaRow[8], ":") {
t.Errorf("link_id should contain MAC separator ':', got %q", deltaRow[8])
}
}
// TestDeltaRMSComputation tests that deltaRMS values are physically plausible
func TestDeltaRMSComputation(t *testing.T) {
tx := Point{X: 0, Y: 0, Z: 2}
rx := Point{X: 5, Y: 0, Z: 2}
tests := []struct {
name string
walker Point
minRMS float64
maxRMS float64
}{
{
name: "Walker on direct line (zone 1)",
walker: Point{X: 2.5, Y: 0, Z: 1.7},
minRMS: 0.1, // zone 1 should have high deltaRMS
maxRMS: 0.2,
},
{
name: "Walker far off axis (zone 5+)",
walker: Point{X: 2.5, Y: 10, Z: 1.7},
minRMS: 0.0,
maxRMS: 0.02, // should be very low
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rms := computeWalkerDeltaRMS(tx, rx, tt.walker)
if rms < tt.minRMS || rms > tt.maxRMS {
t.Errorf("deltaRMS = %v, expected range [%v, %v]", rms, tt.minRMS, tt.maxRMS)
}
})
}
}
// TestWallFlagParsing tests that --wall flag parsing works
func TestWallFlagParsing(t *testing.T) {
// Reset walls
walls = nil
f := &wallFlag{}
if err := f.Set("1.0,2.0,3.0,4.0"); err != nil {
t.Fatalf("Failed to parse wall: %v", err)
}
if err := f.Set("5.0,6.0,7.0,8.0"); err != nil {
t.Fatalf("Failed to parse second wall: %v", err)
}
if len(walls) != 2 {
t.Fatalf("Expected 2 walls, got %d", len(walls))
}
if walls[0].X1 != 1.0 || walls[0].Y1 != 2.0 || walls[0].X2 != 3.0 || walls[0].Y2 != 4.0 {
t.Errorf("Wall 0: got %+v", walls[0])
}
if walls[1].X1 != 5.0 || walls[1].Y1 != 6.0 || walls[1].X2 != 7.0 || walls[1].Y2 != 8.0 {
t.Errorf("Wall 1: got %+v", walls[1])
}
// Check default material (drywall) and attenuation
if walls[0].Material != MaterialDrywall {
t.Errorf("Wall 0 material = %v, want drywall", walls[0].Material)
}
if walls[0].Attenuation != 3.0 {
t.Errorf("Wall 0 attenuation = %v, want 3.0", walls[0].Attenuation)
}
// Test invalid format
if err := f.Set("1,2,3"); err == nil {
t.Error("Expected error for 3-part wall spec")
}
}
// TestWalkerBounce tests that walkers bounce off room walls
func TestWalkerBounce(t *testing.T) {
space := &Space{Width: 5, Depth: 5, Height: 2.5}
rng := rand.New(rand.NewSource(42))
walker := &Walker{
ID: 0,
Type: WalkerTypeRandomWalk,
Position: Point{X: 0.1, Y: 2.5, Z: 1.7}, // Near left wall
Velocity: Point{X: -1.0, Y: 0, Z: 0}, // Moving left
Speed: 1.0,
Height: 1.7,
}
// Run several updates — should bounce off left wall
for i := 0; i < 10; i++ {
updateWalkers([]*Walker{walker}, space, rng)
}
// Walker should not be outside room bounds
if walker.Position.X < 0 || walker.Position.X > space.Width {
t.Errorf("Walker X=%v outside room [0, %v]", walker.Position.X, space.Width)
}
if walker.Position.Y < 0 || walker.Position.Y > space.Depth {
t.Errorf("Walker Y=%v outside room [0, %v]", walker.Position.Y, space.Depth)
}
}
// TestPathFollowing tests that walkers follow a predefined path
func TestPathFollowing(t *testing.T) {
space := &Space{Width: 5, Depth: 5, Height: 2.5}
// Create a rectangular path
path := []Point{
{X: 0.5, Y: 0.5, Z: 1.7},
{X: 4.5, Y: 0.5, Z: 1.7},
{X: 4.5, Y: 4.5, Z: 1.7},
{X: 0.5, Y: 4.5, Z: 1.7},
}
walker := &Walker{
ID: 0,
Type: WalkerTypePathFollow,
Path: path,
PathIdx: 0,
Speed: 1.0,
Height: 1.7,
Position: path[0], // Initialize at first waypoint
}
// Run updates - should move along path
for i := 0; i < 100; i++ {
updatePathWalker(walker, 0.1)
}
// Walker should have moved from starting position
if walker.Position == path[0] {
t.Error("Walker should have moved from starting position")
}
// Walker should remain within room bounds
if walker.Position.X < 0 || walker.Position.X > space.Width {
t.Errorf("Walker X=%v outside room [0, %v]", walker.Position.X, space.Width)
}
if walker.Position.Y < 0 || walker.Position.Y > space.Depth {
t.Errorf("Walker Y=%v outside room [0, %v]", walker.Position.Y, space.Depth)
}
}
// TestNodeToNodeTraversal tests node-to-node walker movement
func TestNodeToNodeTraversal(t *testing.T) {
space := &Space{Width: 5, Depth: 5, Height: 2.5}
nodes := []*VirtualNode{
{ID: 0, Position: Point{X: 0.5, Y: 0.5, Z: 2.0}},
{ID: 1, Position: Point{X: 4.5, Y: 0.5, Z: 2.0}},
{ID: 2, Position: Point{X: 2.5, Y: 4.5, Z: 2.0}},
}
walker := &Walker{
ID: 0,
Type: WalkerTypeNodeToNode,
Nodes: nodes,
NodeIdx: 1, // Target is second node
Speed: 1.0,
Height: 1.7,
Position: nodes[0].Position, // Initialize at first node
}
// Move towards second node
for i := 0; i < 100; i++ {
updateNodeToNodeWalker(walker, 0.1, space)
// Break if we've moved to the next node
if walker.NodeIdx != 1 {
break
}
}
// Walker should have progressed toward the target
distToTarget := math.Sqrt(
math.Pow(walker.Position.X-nodes[1].Position.X, 2) +
math.Pow(walker.Position.Y-nodes[1].Position.Y, 2))
if distToTarget > 1.0 {
t.Errorf("Walker should be closer to target; distance=%v", distToTarget)
}
}
// TestPathFileLoading tests loading paths from JSON file
func TestPathFileLoading(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "paths.json")
// Create test path file
paths := []PathDefinition{
{
Waypoints: []Point{
{X: 0, Y: 0, Z: 1.7},
{X: 1, Y: 0, Z: 1.7},
{X: 1, Y: 1, Z: 1.7},
{X: 0, Y: 1, Z: 1.7},
},
},
}
data, err := json.Marshal(paths)
if err != nil {
t.Fatalf("Failed to marshal paths: %v", err)
}
if err := os.WriteFile(pathFile, data, 0644); err != nil {
t.Fatalf("Failed to write path file: %v", err)
}
// Load paths
loaded, err := loadPathsFromFile(pathFile)
if err != nil {
t.Fatalf("Failed to load paths: %v", err)
}
if len(loaded) != 1 {
t.Errorf("Expected 1 path, got %d", len(loaded))
}
if len(loaded[0]) != 4 {
t.Errorf("Expected 4 waypoints, got %d", len(loaded[0]))
}
}
// TestPathFileInvalid tests error handling for invalid path files
func TestPathFileInvalid(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "invalid.json")
// Write invalid JSON
if err := os.WriteFile(pathFile, []byte("not valid json"), 0644); err != nil {
t.Fatalf("Failed to write file: %v", err)
}
_, err := loadPathsFromFile(pathFile)
if err == nil {
t.Error("Expected error for invalid JSON")
}
}
// TestDefaultPathWalker tests default rectangular path walker creation
func TestDefaultPathWalker(t *testing.T) {
space := &Space{Width: 5, Depth: 5, Height: 2.5}
walkers := createDefaultPathWalkers(1, space)
if len(walkers) != 1 {
t.Fatalf("Expected 1 walker, got %d", len(walkers))
}
walker := walkers[0]
if walker.Type != WalkerTypePathFollow {
t.Errorf("Expected path-following walker, got %v", walker.Type)
}
if len(walker.Path) != 4 {
t.Errorf("Expected 4 waypoints in default path, got %d", len(walker.Path))
}
// Verify path forms a rectangle around the room
path := walker.Path
margin := 0.5
// First point: bottom-left
if path[0].X != margin || path[0].Y != margin {
t.Errorf("First waypoint should be bottom-left: got %v", path[0])
}
// Second point: bottom-right
if path[1].X != space.Width-margin || path[1].Y != margin {
t.Errorf("Second waypoint should be bottom-right: got %v", path[1])
}
// Third point: top-right
if path[2].X != space.Width-margin || path[2].Y != space.Depth-margin {
t.Errorf("Third waypoint should be top-right: got %v", path[2])
}
// Fourth point: top-left
if path[3].X != margin || path[3].Y != space.Depth-margin {
t.Errorf("Fourth waypoint should be top-left: got %v", path[3])
}
}
// TestReflectionPointVerticalWall tests reflection point calculation for vertical walls
func TestReflectionPointVerticalWall(t *testing.T) {
// Vertical wall at x=2.5 from y=0 to y=5
wall := Wall{X1: 2.5, Y1: 0, X2: 2.5, Y2: 5, Material: MaterialDrywall, Attenuation: 3.0}
// TX and RX on opposite sides of the wall, at different Y positions
// This should create a valid reflection
tx := Point{X: 1, Y: 1, Z: 2}
rx := Point{X: 3, Y: 3, Z: 2}
reflPoint, ok := findReflectionPoint(tx, rx, wall)
if !ok {
t.Fatal("Failed to find reflection point")
}
// Reflection point should be on the wall at x=2.5
if reflPoint.X != 2.5 {
t.Errorf("Reflection X = %v, want 2.5", reflPoint.X)
}
// Y should be between 0 and 5
if reflPoint.Y < 0 || reflPoint.Y > 5 {
t.Errorf("Reflection Y = %v outside wall bounds [0, 5]", reflPoint.Y)
}
// Z should be average of TX and RX Z
expectedZ := (tx.Z + rx.Z) / 2.0
if math.Abs(reflPoint.Z-expectedZ) > 1e-6 {
t.Errorf("Reflection Z = %v, want %v", reflPoint.Z, expectedZ)
}
}
// TestReflectionPointHorizontalWall tests reflection point calculation for horizontal walls
func TestReflectionPointHorizontalWall(t *testing.T) {
// Horizontal wall at y=2.5 from x=0 to x=5
wall := Wall{X1: 0, Y1: 2.5, X2: 5, Y2: 2.5, Material: MaterialDrywall, Attenuation: 3.0}
// TX and RX on opposite sides of the wall, at different X positions
tx := Point{X: 1, Y: 1, Z: 2}
rx := Point{X: 3, Y: 3, Z: 2}
reflPoint, ok := findReflectionPoint(tx, rx, wall)
if !ok {
t.Fatal("Failed to find reflection point")
}
// Reflection point should be on the wall at y=2.5
if reflPoint.Y != 2.5 {
t.Errorf("Reflection Y = %v, want 2.5", reflPoint.Y)
}
// X should be between 0 and 5
if reflPoint.X < 0 || reflPoint.X > 5 {
t.Errorf("Reflection X = %v outside wall bounds [0, 5]", reflPoint.X)
}
// Z should be average of TX and RX Z
expectedZ := (tx.Z + rx.Z) / 2.0
if math.Abs(reflPoint.Z-expectedZ) > 1e-6 {
t.Errorf("Reflection Z = %v, want %v", reflPoint.Z, expectedZ)
}
}
// TestReflectionPointOutOfBounds tests that reflections outside wall bounds fail
func TestReflectionPointOutOfBounds(t *testing.T) {
// Vertical wall at x=2.5 from y=0 to y=2 (walker Y=2.5 is outside bounds)
wall := Wall{X1: 2.5, Y1: 0, X2: 2.5, Y2: 2, Material: MaterialDrywall, Attenuation: 3.0}
tx := Point{X: 1, Y: 2.5, Z: 2}
rx := Point{X: 4, Y: 2.5, Z: 2}
_, ok := findReflectionPoint(tx, rx, wall)
if ok {
t.Error("Expected no reflection point for out-of-bounds Y")
}
}
// TestReflectPointAcrossLine tests point reflection across a line
func TestReflectPointAcrossLine(t *testing.T) {
p := Point{X: 1, Y: 1, Z: 0}
lineStart := Point{X: 0, Y: 0, Z: 0}
lineEnd := Point{X: 2, Y: 0, Z: 0} // Horizontal line at y=0
refl := reflectPointAcrossLine(p, lineStart, lineEnd)
// Point (1,1) reflected across y=0 should be (1,-1)
if refl.X != 1 {
t.Errorf("Refl X = %v, want 1", refl.X)
}
if refl.Y != -1 {
t.Errorf("Refl Y = %v, want -1", refl.Y)
}
}
// TestLineIntersection tests line segment intersection
func TestLineIntersection(t *testing.T) {
tests := []struct {
name string
p1, p2 Point // First line segment
p3, p4 Point // Second line segment
wantOK bool
wantX float64
wantY float64
}{
{
name: "Crossing lines",
p1: Point{X: 0, Y: 0, Z: 0},
p2: Point{X: 2, Y: 2, Z: 0},
p3: Point{X: 0, Y: 2, Z: 0},
p4: Point{X: 2, Y: 0, Z: 0},
wantOK: true,
wantX: 1,
wantY: 1,
},
{
name: "Parallel lines",
p1: Point{X: 0, Y: 0, Z: 0},
p2: Point{X: 1, Y: 0, Z: 0},
p3: Point{X: 0, Y: 1, Z: 0},
p4: Point{X: 1, Y: 1, Z: 0},
wantOK: false,
},
{
name: "Non-intersecting segments",
p1: Point{X: 0, Y: 0, Z: 0},
p2: Point{X: 1, Y: 0, Z: 0},
p3: Point{X: 2, Y: 0, Z: 0},
p4: Point{X: 3, Y: 0, Z: 0},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pt, ok := lineIntersection(tt.p1, tt.p2, tt.p3, tt.p4)
if ok != tt.wantOK {
t.Errorf("lineIntersection() ok = %v, want %v", ok, tt.wantOK)
return
}
if tt.wantOK {
if math.Abs(pt.X-tt.wantX) > 1e-6 {
t.Errorf("X = %v, want %v", pt.X, tt.wantX)
}
if math.Abs(pt.Y-tt.wantY) > 1e-6 {
t.Errorf("Y = %v, want %v", pt.Y, tt.wantY)
}
}
})
}
}
// TestWallMaterialProperties tests that different wall materials have correct attenuation
func TestWallMaterialProperties(t *testing.T) {
walls = nil // Reset
f := &wallFlag{}
// Test drywall (default)
if err := f.Set("0,0,5,0"); err != nil {
t.Fatalf("Failed to parse drywall wall: %v", err)
}
if len(walls) != 1 || walls[0].Attenuation != 3.0 {
t.Errorf("Drywall attenuation = %v, want 3.0", walls[0].Attenuation)
}
if walls[0].Material != MaterialDrywall {
t.Errorf("Material = %v, want drywall", walls[0].Material)
}
// Test brick
walls = nil
if err := f.Set("0,1,5,1,brick"); err != nil {
t.Fatalf("Failed to parse brick wall: %v", err)
}
if walls[0].Attenuation != 10.0 {
t.Errorf("Brick attenuation = %v, want 10.0", walls[0].Attenuation)
}
// Test concrete
walls = nil
if err := f.Set("0,2,5,2,concrete"); err != nil {
t.Fatalf("Failed to parse concrete wall: %v", err)
}
if walls[0].Attenuation != 10.0 {
t.Errorf("Concrete attenuation = %v, want 10.0", walls[0].Attenuation)
}
// Test glass
walls = nil
if err := f.Set("0,3,5,3,glass"); err != nil {
t.Fatalf("Failed to parse glass wall: %v", err)
}
if walls[0].Attenuation != 2.0 {
t.Errorf("Glass attenuation = %v, want 2.0", walls[0].Attenuation)
}
// Test metal
walls = nil
if err := f.Set("0,4,5,4,metal"); err != nil {
t.Fatalf("Failed to parse metal wall: %v", err)
}
if walls[0].Attenuation != 20.0 {
t.Errorf("Metal attenuation = %v, want 20.0", walls[0].Attenuation)
}
// Test invalid material
walls = nil
if err := f.Set("0,5,5,5,invalid"); err == nil {
t.Error("Expected error for invalid material")
}
}
// TestTwoRayModel tests that CSI includes both direct and reflected contributions
func TestTwoRayModel(t *testing.T) {
tx := &VirtualNode{ID: 0, Position: Point{X: 1, Y: 1, Z: 2}}
rx := &VirtualNode{ID: 1, Position: Point{X: 3, Y: 3, Z: 2}}
walkers := []*Walker{{ID: 0, Position: Point{X: 2, Y: 2, Z: 1.7}}}
// No walls - should only have direct path
ampNoWall, _ := computeCSIForWalkers(tx, rx, walkers, nil)
if ampNoWall <= 0 {
t.Errorf("Amplitude with no wall = %v, want > 0", ampNoWall)
}
// Add a wall positioned to create a valid reflection
// Wall from (0,0) to (0,5) - vertical wall at x=0
walls := []Wall{{X1: 0, Y1: 0, X2: 0, Y2: 5, Material: MaterialDrywall, Attenuation: 3.0}}
ampWithWall, _ := computeCSIForWalkers(tx, rx, walkers, walls)
// The reflection should affect the CSI - check that amplitude is non-zero
if ampWithWall <= 0 {
t.Errorf("Amplitude with wall = %v, want > 0", ampWithWall)
}
// Verify that computeFirstOrderReflection actually returns values when there's a valid wall geometry
reflAmp, reflPhase := computeFirstOrderReflection(tx.Position, rx.Position, walkers[0].Position, walls)
if reflAmp == 0 && reflPhase == 0 {
t.Error("Expected non-zero reflection contribution with wall present (valid geometry)")
}
// Test with wall positioned such that no valid reflection exists
// Use a wall where the reflection point would fall outside the wall segment bounds
// TX at (1,1), RX at (3,3), vertical wall at x=2 from y=10 to y=15
// The reflection of TX across x=2 would be at (3,1). The line from (3,1) to (3,3)
// is vertical at x=3, which never intersects the wall at x=2.
wallsFar := []Wall{{X1: 2, Y1: 10, X2: 2, Y2: 15, Material: MaterialDrywall, Attenuation: 3.0}}
_, reflPhaseFar := computeFirstOrderReflection(tx.Position, rx.Position, walkers[0].Position, wallsFar)
if reflPhaseFar != 0 {
t.Error("Expected zero reflection contribution with wall that doesn't create valid reflection geometry")
}
}
// TestPathLossModel tests the log-distance path loss calculation
func TestPathLossModel(t *testing.T) {
tx := Point{X: 0, Y: 0, Z: 2}
rx := Point{X: 0, Y: 0, Z: 2}
walker := Point{X: 1, Y: 0, Z: 1.7}
// Close walker should produce higher amplitude than far walker
closeAmp, _ := computeDirectPath(tx, rx, walker, nil)
farWalker := Point{X: 10, Y: 0, Z: 1.7}
farAmp, _ := computeDirectPath(tx, rx, farWalker, nil)
if farAmp >= closeAmp {
t.Errorf("Far walker amplitude %v >= close walker amplitude %v", farAmp, closeAmp)
}
}