From d60bda02571b358156ce112894362ea0f2095c47 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Fri, 17 Jul 2026 16:43:33 -0400 Subject: [PATCH] server: e2e sweatbox visibility, FP, delete, kick/kill lifecycle --- internal/server/e2e_sweatbox_test.go | 777 +++++++++++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 internal/server/e2e_sweatbox_test.go diff --git a/internal/server/e2e_sweatbox_test.go b/internal/server/e2e_sweatbox_test.go new file mode 100644 index 0000000..51eeee6 --- /dev/null +++ b/internal/server/e2e_sweatbox_test.go @@ -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) + } +}