server: e2e sweatbox visibility, FP, delete, kick/kill lifecycle

This commit is contained in:
Reese Norris
2026-07-17 16:43:33 -04:00
parent 331b711d8f
commit d60bda0257

View File

@@ -0,0 +1,777 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/renorris/openfsd/internal/server"
"github.com/renorris/openfsd/pkg/fsdclient"
"github.com/renorris/openfsd/pkg/protocol"
)
// KBTV field center (approx) for ATC/pilot geo placement.
const (
kbtvLat = 44.47
kbtvLon = -73.15
)
// sweatboxDefaultCID is the host default (SWEATBOX_CID / 900001).
const sweatboxDefaultCID = 900001
// ---------------------------------------------------------------------------
// Sweatbox service-HTTP helpers (e2e, real StartTestServer)
// ---------------------------------------------------------------------------
func readSweatboxFixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("..", "sweatbox", "testdata", name)
b, err := os.ReadFile(path)
if err != nil {
b, err = os.ReadFile(filepath.Join("internal", "sweatbox", "testdata", name))
}
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return b
}
func serviceAuth(t *testing.T, ts *server.TestServer) (tok string, client *http.Client) {
t.Helper()
tok, err := ts.MakeServiceJWT()
if err != nil {
t.Fatal(err)
}
return tok, serviceClient()
}
func doServiceHTTP(t *testing.T, ts *server.TestServer, method, path string, body []byte, contentType string) (int, []byte) {
t.Helper()
tok, client := serviceAuth(t, ts)
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, ts.HTTPBaseURL()+path, rdr)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+tok)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return resp.StatusCode, data
}
func loadKBTVAirport(t *testing.T, ts *server.TestServer) {
t.Helper()
apt := readSweatboxFixture(t, "KBTV_example.apt")
code, body := doServiceHTTP(t, ts, http.MethodPost, "/sweatbox/airport", apt, "text/plain")
if code != http.StatusOK {
t.Fatalf("load airport status %d body %s", code, body)
}
}
func loadKBTVScenario(t *testing.T, ts *server.TestServer) int {
t.Helper()
air := readSweatboxFixture(t, "KBTV_example.air")
code, body := doServiceHTTP(t, ts, http.MethodPost, "/sweatbox/scenario", air, "text/plain")
if code != http.StatusOK {
t.Fatalf("load scenario status %d body %s", code, body)
}
var res server.SweatboxScenarioResponse
if err := json.Unmarshal(body, &res); err != nil {
t.Fatalf("scenario response: %v body %s", err, body)
}
if res.Loaded < 1 {
t.Fatalf("expected loaded aircraft, got %+v", res)
}
return res.Loaded
}
func sweatboxCommand(t *testing.T, ts *server.TestServer, cmd string) server.SweatboxCommandResponse {
t.Helper()
payload, _ := json.Marshal(map[string]string{"command": cmd})
code, body := doServiceHTTP(t, ts, http.MethodPost, "/sweatbox/command", payload, "application/json")
if code != http.StatusOK && code != http.StatusConflict {
t.Fatalf("command %q status %d body %s", cmd, code, body)
}
var res server.SweatboxCommandResponse
if err := json.Unmarshal(body, &res); err != nil {
t.Fatalf("command response: %v body %s", err, body)
}
return res
}
func sweatboxPause(t *testing.T, ts *server.TestServer) {
t.Helper()
code, body := doServiceHTTP(t, ts, http.MethodPost, "/sweatbox/pause", nil, "")
if code != http.StatusNoContent {
t.Fatalf("pause status %d body %s", code, body)
}
}
func sweatboxUnpause(t *testing.T, ts *server.TestServer) {
t.Helper()
code, body := doServiceHTTP(t, ts, http.MethodPost, "/sweatbox/unpause", nil, "")
if code != http.StatusNoContent {
t.Fatalf("unpause status %d body %s", code, body)
}
}
func getSweatboxState(t *testing.T, ts *server.TestServer) server.SweatboxStateJSON {
t.Helper()
code, body := doServiceHTTP(t, ts, http.MethodGet, "/sweatbox/state", nil, "")
if code != http.StatusOK {
t.Fatalf("state status %d body %s", code, body)
}
var st server.SweatboxStateJSON
if err := json.Unmarshal(body, &st); err != nil {
t.Fatalf("state decode: %v body %s", err, body)
}
return st
}
func aircraftFromState(st server.SweatboxStateJSON, callsign string) (server.SweatboxAircraftJSON, bool) {
cs := strings.ToUpper(callsign)
for _, ac := range st.Aircraft {
if strings.EqualFold(ac.Callsign, cs) {
return ac, true
}
}
return server.SweatboxAircraftJSON{}, false
}
func waitSweatboxAircraft(t *testing.T, ts *server.TestServer, callsign string) server.SweatboxAircraftJSON {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st := getSweatboxState(t, ts)
if ac, ok := aircraftFromState(st, callsign); ok {
return ac
}
time.Sleep(30 * time.Millisecond)
}
t.Fatalf("aircraft %s not in sweatbox state", callsign)
return server.SweatboxAircraftJSON{}
}
func waitOnlineCallsignGone(t *testing.T, ts *server.TestServer, callsign string) {
t.Helper()
tok, client := serviceAuth(t, ts)
deadline := time.Now().Add(5 * time.Second)
url := ts.HTTPBaseURL() + "/online_users"
for time.Now().Before(deadline) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := client.Do(req)
if err != nil {
time.Sleep(20 * time.Millisecond)
continue
}
var data server.OnlineUsersResponseData
err = json.NewDecoder(resp.Body).Decode(&data)
_ = resp.Body.Close()
if err != nil {
time.Sleep(20 * time.Millisecond)
continue
}
found := false
for _, p := range data.Pilots {
if p.Callsign == callsign {
found = true
break
}
}
if !found {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("callsign %s still in online_users", callsign)
}
func waitSweatboxAircraftGone(t *testing.T, ts *server.TestServer, callsign string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st := getSweatboxState(t, ts)
if _, ok := aircraftFromState(st, callsign); !ok {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("aircraft %s still in sweatbox state", callsign)
}
// nearKBTVATC returns a tower position over Burlington.
func nearKBTVATC(cs string, rating protocol.NetworkRating) protocol.ATCPosition {
return protocol.ATCPosition{
Callsign: cs,
Frequencies: "11850",
FacilityType: 4, // TWR
VisibilityRange: 50,
NetworkRating: rating,
Latitude: kbtvLat,
Longitude: kbtvLon,
}
}
// nearKBTVPilot returns a pilot position near the field.
func nearKBTVPilot(cs string) protocol.PilotPosition {
return protocol.PilotPosition{
TransponderMode: "S",
Callsign: cs,
TransponderCode: "1200",
NetworkRating: protocol.NetworkRatingObserver,
Latitude: kbtvLat,
Longitude: kbtvLon,
TrueAltitude: 1000,
Groundspeed: 0,
PitchBankHeading: 0,
}
}
// sendATCGeo indexes the ATC client near KBTV (predicate-poll via online_users).
func sendATCGeo(t *testing.T, ts *server.TestServer, c *fsdclient.Client, cs string, rating protocol.NetworkRating) {
t.Helper()
pos := nearKBTVATC(cs, rating)
tok, client := serviceAuth(t, ts)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := c.SendATCPosition(pos); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, ts.HTTPBaseURL()+"/online_users", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := client.Do(req)
if err != nil {
time.Sleep(20 * time.Millisecond)
continue
}
var data server.OnlineUsersResponseData
_ = json.NewDecoder(resp.Body).Decode(&data)
_ = resp.Body.Close()
for _, a := range data.ATC {
if a.Callsign == cs && math.Abs(a.Latitude-kbtvLat) < 0.05 {
return
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("ATC %s geo not indexed near KBTV", cs)
}
// collectWire watches c until pred matches both #AP and @ for callsign (or ctx ends).
// Must start before the event that emits the packets (race-safe).
func collectWire(ctx context.Context, c *fsdclient.Client, callsign string) (sawAP, sawPos bool, err error) {
cs := []byte(callsign)
for {
if sawAP && sawPos {
return sawAP, sawPos, nil
}
r, e := c.Next(ctx)
if e != nil {
return sawAP, sawPos, e
}
switch r.Type {
case protocol.PacketTypeAddPilot:
if bytes.Contains(r.Raw, cs) {
sawAP = true
}
case protocol.PacketTypePilotPosition:
if bytes.Contains(r.Raw, cs) {
sawPos = true
}
}
}
}
// ---------------------------------------------------------------------------
// E2E scenarios
// ---------------------------------------------------------------------------
// TestE2E_Sweatbox_ATCVisibility: ATC online near KBTV sees #AP and @ after scenario load.
func TestE2E_Sweatbox_ATCVisibility(t *testing.T) {
ts := server.StartTestServer(t)
atc := dial(t, ts)
const atcCS = "BTV_TWR"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
sendATCGeo(t, ts, atc, atcCS, protocol.NetworkRatingController1)
const ac = "AAL123"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
var sawAP, sawPos bool
var collectErr error
go func() {
defer wg.Done()
sawAP, sawPos, collectErr = collectWire(ctx, atc, ac)
}()
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
wg.Wait()
if collectErr != nil && !(sawAP && sawPos) {
t.Fatalf("ATC did not see both #AP and @ for %s: ap=%v pos=%v err=%v (recv=%v)",
ac, sawAP, sawPos, collectErr, atc.Recorder().Received())
}
if !sawAP {
t.Fatalf("ATC missed #AP for %s (recv=%v)", ac, atc.Recorder().Received())
}
if !sawPos {
t.Fatalf("ATC missed @ for %s (recv=%v)", ac, atc.Recorder().Received())
}
// Confirm registry / service HTTP also list the synthetic.
_ = waitOnlinePilot(t, ts, ac, sweatboxDefaultCID)
}
// TestE2E_Sweatbox_FPQuery: ATC $CQ SERVER:FP returns plan for a synthetic aircraft.
func TestE2E_Sweatbox_FPQuery(t *testing.T) {
ts := server.StartTestServer(t)
atc := dial(t, ts)
const atcCS = "FP_BTV"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
_ = waitOnlinePilot(t, ts, "AAL123", sweatboxDefaultCID)
// Drain any initial $FP broadcast so the query response is unambiguous.
drainCtx, drainCancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
for {
_, err := atc.Next(drainCtx)
if err != nil {
break
}
}
drainCancel()
if err := atc.Send([]byte("$CQ" + atcCS + ":SERVER:FP:AAL123\r\n")); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := atc.WaitFor(ctx, func(r fsdclient.Received) bool {
return r.Type == protocol.PacketTypeFlightPlan &&
bytes.Contains(r.Raw, []byte("AAL123")) &&
bytes.Contains(r.Raw, []byte("KBTV")) &&
bytes.Contains(r.Raw, []byte("KBOS"))
})
if err != nil {
t.Fatalf("FP query: %v (recorder=%v)", err, atc.Recorder().All())
}
if !bytes.Contains(got.Raw, []byte("B738")) {
t.Fatalf("expected aircraft type in FP, got %q", got.Raw)
}
}
// TestE2E_Sweatbox_CallsignConflict: human cannot log on with a sweatbox callsign.
func TestE2E_Sweatbox_CallsignConflict(t *testing.T) {
ts := server.StartTestServer(t)
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
_ = waitOnlinePilot(t, ts, "AAL123", sweatboxDefaultCID)
c := dial(t, ts)
loginPilot(t, c, "AAL123", ts.PilotCID, ts.PilotPassword, protocol.NetworkRatingObserver)
waitError(t, c, protocol.CallsignInUseError)
}
// TestE2E_Sweatbox_PauseFreezesMotion: taxi motion then pause keeps lat/lon stable.
func TestE2E_Sweatbox_PauseFreezesMotion(t *testing.T) {
ts := server.StartTestServer(t)
loadKBTVAirport(t, ts)
// Park GA9 then taxi toward runway via J.
res := sweatboxCommand(t, ts, "add v s p @GA9")
if !res.OK {
t.Fatalf("add: %+v", res)
}
st := getSweatboxState(t, ts)
if len(st.Aircraft) < 1 {
t.Fatal("expected aircraft after add")
}
cs := st.Aircraft[0].Callsign
res = sweatboxCommand(t, ts, cs+" taxi J 33")
if !res.OK {
t.Fatalf("taxi: %+v", res)
}
sweatboxUnpause(t, ts)
// Wait until position moves from the parking snap.
base := waitSweatboxAircraft(t, ts, cs)
deadline := time.Now().Add(12 * time.Second)
var moved server.SweatboxAircraftJSON
for time.Now().Before(deadline) {
ac := waitSweatboxAircraft(t, ts, cs)
if math.Abs(ac.Lat-base.Lat) > 1e-5 || math.Abs(ac.Lon-base.Lon) > 1e-5 {
moved = ac
break
}
time.Sleep(100 * time.Millisecond)
}
if moved.Callsign == "" {
t.Fatalf("expected motion after unpause; base lat/lon=%.6f,%.6f", base.Lat, base.Lon)
}
sweatboxPause(t, ts)
// Allow one in-flight tick to settle, then sample freeze window.
time.Sleep(200 * time.Millisecond)
frozen := waitSweatboxAircraft(t, ts, cs)
// While paused, positions must remain stable across several host ticks (~1s each).
stableUntil := time.Now().Add(2500 * time.Millisecond)
for time.Now().Before(stableUntil) {
ac := waitSweatboxAircraft(t, ts, cs)
if math.Abs(ac.Lat-frozen.Lat) > 1e-7 || math.Abs(ac.Lon-frozen.Lon) > 1e-7 {
t.Fatalf("position moved while paused: was %.8f,%.8f now %.8f,%.8f",
frozen.Lat, frozen.Lon, ac.Lat, ac.Lon)
}
time.Sleep(200 * time.Millisecond)
}
}
// TestE2E_Sweatbox_KickLifecycle: service HTTP kick removes registry + engine aircraft.
func TestE2E_Sweatbox_KickLifecycle(t *testing.T) {
ts := server.StartTestServer(t)
atc := dial(t, ts)
const atcCS = "KICK_TWR"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
const ac = "AAL123"
_ = waitOnlinePilot(t, ts, ac, sweatboxDefaultCID)
// Watch for #DP from kick path.
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
var sawDP bool
var dpErr error
go func() {
defer wg.Done()
_, dpErr = atc.WaitFor(ctx, func(r fsdclient.Received) bool {
return r.Type == protocol.PacketTypeDeletePilot && bytes.Contains(r.Raw, []byte(ac))
})
sawDP = dpErr == nil
}()
tok, client := serviceAuth(t, ts)
kickBody := strings.NewReader(fmt.Sprintf(`{"callsign":"%s"}`, ac))
req, err := http.NewRequest(http.MethodPost, ts.HTTPBaseURL()+"/kick_user", kickBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("kick status %d body %s", resp.StatusCode, body)
}
waitOnlineCallsignGone(t, ts, ac)
waitSweatboxAircraftGone(t, ts, ac)
wg.Wait()
if !sawDP {
t.Fatalf("ATC did not receive #DP for %s: %v (recv=%v)", ac, dpErr, atc.Recorder().Received())
}
}
// TestE2E_Sweatbox_KillLifecycle: supervisor $!! removes synthetic from registry + engine.
func TestE2E_Sweatbox_KillLifecycle(t *testing.T) {
ts := server.StartTestServer(t)
sup := dial(t, ts)
const supCS = "E2E_SUP2"
loginATC(t, sup, supCS, ts.SupCID, ts.SupPassword, protocol.NetworkRatingSupervisor)
waitMOTD(t, sup, supCS)
// Peer ATC for #DP observation (supervisor also receives it via broadcastAll).
atc := dial(t, ts)
const atcCS = "KILL_TWR"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
const ac = "USA456"
_ = waitOnlinePilot(t, ts, ac, sweatboxDefaultCID)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
var sawDP bool
var dpErr error
go func() {
defer wg.Done()
_, dpErr = atc.WaitFor(ctx, func(r fsdclient.Received) bool {
return r.Type == protocol.PacketTypeDeletePilot && bytes.Contains(r.Raw, []byte(ac))
})
sawDP = dpErr == nil
}()
if err := sup.Send([]byte("$!!" + supCS + ":" + ac + ":e2e sweatbox kill")); err != nil {
t.Fatal(err)
}
waitOnlineCallsignGone(t, ts, ac)
waitSweatboxAircraftGone(t, ts, ac)
wg.Wait()
if !sawDP {
t.Fatalf("ATC did not receive #DP for %s: %v (recv=%v)", ac, dpErr, atc.Recorder().Received())
}
}
// TestE2E_Sweatbox_HTTPDelete: DELETE /sweatbox/aircraft/:cs removes synthetic + #DP.
func TestE2E_Sweatbox_HTTPDelete(t *testing.T) {
ts := server.StartTestServer(t)
atc := dial(t, ts)
const atcCS = "DEL_TWR"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
const ac = "N4729H"
_ = waitOnlinePilot(t, ts, ac, sweatboxDefaultCID)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
var sawDP bool
var dpErr error
go func() {
defer wg.Done()
_, dpErr = atc.WaitFor(ctx, func(r fsdclient.Received) bool {
return r.Type == protocol.PacketTypeDeletePilot && bytes.Contains(r.Raw, []byte(ac))
})
sawDP = dpErr == nil
}()
code, body := doServiceHTTP(t, ts, http.MethodDelete, "/sweatbox/aircraft/"+ac, nil, "")
if code != http.StatusNoContent {
t.Fatalf("DELETE status %d body %s", code, body)
}
waitOnlineCallsignGone(t, ts, ac)
waitSweatboxAircraftGone(t, ts, ac)
wg.Wait()
if !sawDP {
t.Fatalf("ATC did not receive #DP for %s: %v", ac, dpErr)
}
}
// TestE2E_Sweatbox_HumanPilotRangedPos: human pilot in range receives sweatbox @.
func TestE2E_Sweatbox_HumanPilotRangedPos(t *testing.T) {
ts := server.StartTestServer(t)
pilot := dial(t, ts)
const pilotCS = "NEAR_SB"
loginPilot(t, pilot, pilotCS, ts.PilotCID, ts.PilotPassword, protocol.NetworkRatingObserver)
waitMOTD(t, pilot, pilotCS)
// Index pilot geo near KBTV before synthetics register (so @ is in range).
tok, client := serviceAuth(t, ts)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := pilot.SendPilotPosition(nearKBTVPilot(pilotCS)); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, ts.HTTPBaseURL()+"/online_users", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := client.Do(req)
if err != nil {
time.Sleep(20 * time.Millisecond)
continue
}
var data server.OnlineUsersResponseData
_ = json.NewDecoder(resp.Body).Decode(&data)
_ = resp.Body.Close()
for _, p := range data.Pilots {
if p.Callsign == pilotCS && math.Abs(p.Latitude-kbtvLat) < 0.05 {
goto pilotIndexed
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("pilot geo not indexed near KBTV")
pilotIndexed:
const ac = "AAL123"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
var sawPos bool
var collectErr error
go func() {
defer wg.Done()
_, collectErr = pilot.WaitFor(ctx, func(r fsdclient.Received) bool {
return r.Type == protocol.PacketTypePilotPosition && bytes.Contains(r.Raw, []byte(ac))
})
sawPos = collectErr == nil
}()
loadKBTVAirport(t, ts)
loadKBTVScenario(t, ts)
wg.Wait()
if !sawPos {
t.Fatalf("pilot did not receive ranged @ for %s: %v (recv=%v)",
ac, collectErr, pilot.Recorder().Received())
}
}
// TestE2E_Sweatbox_TaxiMotion: unpaused taxi changes lat/lon over wall clock.
func TestE2E_Sweatbox_TaxiMotion(t *testing.T) {
ts := server.StartTestServer(t)
loadKBTVAirport(t, ts)
res := sweatboxCommand(t, ts, "add v s p @GA9")
if !res.OK {
t.Fatalf("add: %+v", res)
}
st := getSweatboxState(t, ts)
if len(st.Aircraft) < 1 {
t.Fatal("no aircraft")
}
cs := st.Aircraft[0].Callsign
start := st.Aircraft[0]
res = sweatboxCommand(t, ts, cs+" taxi J 33")
if !res.OK {
t.Fatalf("taxi: %+v", res)
}
// Scenario/command leaves engine paused by default for add? add does not pause,
// but NewEngine starts paused — unpause required.
sweatboxUnpause(t, ts)
deadline := time.Now().Add(12 * time.Second)
for time.Now().Before(deadline) {
ac := waitSweatboxAircraft(t, ts, cs)
dLat := math.Abs(ac.Lat - start.Lat)
dLon := math.Abs(ac.Lon - start.Lon)
if dLat > 1e-5 || dLon > 1e-5 {
// Also observe a wire @ if an ATC is listening — not required for smoke.
t.Logf("taxi motion %s: start=%.6f,%.6f now=%.6f,%.6f Δ=%.6f,%.6f",
cs, start.Lat, start.Lon, ac.Lat, ac.Lon, dLat, dLon)
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("taxi did not move %s within timeout (start %.6f,%.6f)", cs, start.Lat, start.Lon)
}
// TestE2E_Sweatbox_LateJoinATC: ATC joining after synthetics exist eventually sees @
// (no historical #AP — same as human late-join semantics).
func TestE2E_Sweatbox_LateJoinATC(t *testing.T) {
ts := server.StartTestServer(t)
// Existing synthetics first.
loadKBTVAirport(t, ts)
res := sweatboxCommand(t, ts, "add v s p @GA9")
if !res.OK {
t.Fatalf("add: %+v", res)
}
st := getSweatboxState(t, ts)
if len(st.Aircraft) < 1 {
t.Fatal("no aircraft")
}
cs := st.Aircraft[0].Callsign
_ = waitOnlinePilot(t, ts, cs, sweatboxDefaultCID)
// Command taxi + unpause so subsequent ticks rebroadcast @ for late joiners.
res = sweatboxCommand(t, ts, cs+" taxi J 33")
if !res.OK {
t.Fatalf("taxi: %+v", res)
}
sweatboxUnpause(t, ts)
// Late ATC joins after #AP window.
atc := dial(t, ts)
const atcCS = "LATE_TWR"
loginATC(t, atc, atcCS, ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1)
waitMOTD(t, atc, atcCS)
sendATCGeo(t, ts, atc, atcCS, protocol.NetworkRatingController1)
// Must eventually see @; must not require historical #AP (document: no #AP replay).
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
var sawAP, sawPos bool
for {
r, err := atc.Next(ctx)
if err != nil {
break
}
if r.Type == protocol.PacketTypeAddPilot && bytes.Contains(r.Raw, []byte(cs)) {
sawAP = true
}
if r.Type == protocol.PacketTypePilotPosition && bytes.Contains(r.Raw, []byte(cs)) {
sawPos = true
break
}
}
if !sawPos {
t.Fatalf("late ATC never received @ for %s (sawAP=%v recv=%v)",
cs, sawAP, atc.Recorder().Received())
}
// Historical #AP is not guaranteed; if it appeared it would be unexpected but not fatal.
// Document assertion: visibility is via subsequent @ only.
if sawAP {
t.Logf("note: late ATC also saw #AP for %s (not required; ticks may re-add only on new register)", cs)
}
}