sweatbox: add .apt/.air parsers and KBTV fixtures

Introduce pure internal/sweatbox package with TWRTrainer-compatible
airport and scenario parsers, fixtures, unit tests, and import-graph
ownership rules. No server wiring.
This commit is contained in:
Reese Norris
2026-07-17 15:21:44 -04:00
parent e0d7532423
commit cb8bd24d63
9 changed files with 1649 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ Operational rules for agents and humans changing this repository. This file is t
| `internal/session` | Per-connection state + send worker | Does not import postoffice/server |
| `internal/postoffice` | Registry (map/tree of participants) | Depends on session ports, not server |
| `internal/metar` | Worker pool + injectable HTTP | Side-effect boundary |
| `internal/sweatbox` | Pure sim (apt/air parse, taxi, engine, kinematics) | **stdlib + `internal/geo` only**; no protocol/session/server |
| `internal/server` | TCP accept, login, handlers, service HTTP | DI via `server.New` / `server.NewDefault` |
| `internal/db` | Shared repositories + migrations | Used by FSD and web |
| `internal/web` | Gin MPA + progressive enhancement + `/api/v1` | boring-web mandatory |
@@ -30,11 +31,13 @@ Operational rules for agents and humans changing this repository. This file is t
```
cmd/openfsd → internal/server, internal/web, …
internal/server → session, postoffice, protocol, auth, metar, db
internal/server → session, postoffice, protocol, auth, metar, db, sweatbox
internal/postoffice → geo, session
internal/metar → protocol, session
internal/sweatbox → geo
internal/web → auth, db, protocol
(and server only for service-HTTP DTOs until those move)
(and server only for service-HTTP DTOs until those move;
never sweatbox — control plane is service HTTP)
pkg/fsdclient → protocol
internal/auth → protocol
internal/session → protocol
@@ -52,9 +55,10 @@ Enforce with `scripts/check-import-graph.sh`.
| `pkg/fsdclient` | `internal/*` |
| `internal/session` | `postoffice`, `server`, `web`, `metar` |
| `internal/geo` | Any non-stdlib import |
| `internal/web` | `session`, `postoffice`, `metar` |
| `internal/web` | `session`, `postoffice`, `metar`, `sweatbox` |
| `internal/db` | `server`, `session`, `web`, `fsdclient` |
| `internal/auth` | `server`, `session`, `web` |
| `internal/sweatbox` | `server`, `web`, `postoffice`, `session`, `db`, `auth`, `metar`, `fsdclient`, `protocol` |
Stdlib heuristic: first path element contains no `.` (e.g. `fmt`, `net/http`). Third-party is never allowed in `pkg/protocol` or `internal/geo`.
@@ -144,6 +148,7 @@ Enforced by `scripts/check-coverage.sh` (CI).
| `internal/geo` | ≥98% | **Hard** |
| `internal/auth` | ≥95% | **Hard** |
| `internal/postoffice` | ≥90% | **Hard** |
| `internal/sweatbox` | ≥95% | **Hard** (target; may land in coverage script with later PR) |
| `internal/web` | ≥80% | Soft (report only) |
| Overall aspirational | 90% | Soft (report only) |
| `cmd/*` | — | Excluded from measurement |

97
internal/sweatbox/air.go Normal file
View File

@@ -0,0 +1,97 @@
package sweatbox
import (
"fmt"
"strconv"
"strings"
)
// ParseAIR parses TWRTrainer-compatible .air scenario text.
// Valid aircraft rows are returned even when other lines fail (best-effort load);
// errs lists per-line validation issues. Duplicate callsigns are rejected.
func ParseAIR(text string) ([]Aircraft, []string) {
var rows []Aircraft
var errs []string
seen := make(map[string]int) // callsign → first line
lines := strings.Split(text, "\n")
for i, raw := range lines {
lineno := i + 1
line := strings.TrimSpace(raw)
line = strings.TrimSuffix(line, "\r")
if line == "" || strings.HasPrefix(line, ";") {
continue
}
f := strings.Split(line, ":")
if len(f) < 16 {
errs = append(errs, fmt.Sprintf("Invalid number of fields found on line %d", lineno))
continue
}
cs := strings.ToUpper(strings.TrimSpace(f[0]))
if cs == "" {
errs = append(errs, fmt.Sprintf("Missing callsign on line %d", lineno))
continue
}
if first, dup := seen[cs]; dup {
errs = append(errs, fmt.Sprintf("Duplicate callsign (%s) on line %d (first on line %d)", cs, lineno, first))
continue
}
eng := strings.ToUpper(strings.TrimSpace(f[2]))
if eng != EnginePiston && eng != EngineTurboprop && eng != EngineJet && eng != EngineHelicopter {
errs = append(errs, fmt.Sprintf("Invalid engine type on line %d. Must be P, T, J or H.", lineno))
continue
}
rules := strings.ToUpper(strings.TrimSpace(f[3]))
if rules != RulesVFR && rules != RulesIFR && rules != RulesDVFR && rules != RulesSVFR {
errs = append(errs, fmt.Sprintf("Invalid flight plan type on line %d. Must be V, I, D or S.", lineno))
continue
}
mode := strings.ToUpper(strings.TrimSpace(f[10]))
if mode != XPDRModeNormal && mode != XPDRModeStandby {
errs = append(errs, fmt.Sprintf("Invalid transponder mode on line %d. Must be N or S. (Normal or Standby)", lineno))
continue
}
cruiseAlt, err := strconv.ParseFloat(strings.TrimSpace(f[6]), 64)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid numeric field on line %d", lineno))
continue
}
lat, err1 := strconv.ParseFloat(strings.TrimSpace(f[11]), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(f[12]), 64)
alt, err3 := strconv.ParseFloat(strings.TrimSpace(f[13]), 64)
spd, err4 := strconv.ParseFloat(strings.TrimSpace(f[14]), 64)
hdg, err5 := strconv.ParseFloat(strings.TrimSpace(f[15]), 64)
if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil {
errs = append(errs, fmt.Sprintf("Invalid numeric field on line %d", lineno))
continue
}
rec := Aircraft{
Callsign: cs,
Type: strings.ToUpper(strings.TrimSpace(f[1])),
Engine: eng,
Rules: rules,
Dep: strings.ToUpper(strings.TrimSpace(f[4])),
Arr: strings.ToUpper(strings.TrimSpace(f[5])),
CruiseAlt: int(cruiseAlt),
Route: f[7],
Remarks: f[8],
Squawk: strings.TrimSpace(f[9]),
XPDRMode: mode,
Lat: lat,
Lon: lon,
Alt: alt,
Speed: spd,
Heading: hdg,
}
seen[cs] = lineno
rows = append(rows, rec)
}
return rows, errs
}

View File

@@ -0,0 +1,222 @@
package sweatbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseAIR_KBTVFixture(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "KBTV_example.air"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
rows, errs := ParseAIR(string(data))
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3", len(rows))
}
a := rows[0]
if a.Callsign != "AAL123" {
t.Errorf("callsign = %q", a.Callsign)
}
if a.Type != "B738/F" {
t.Errorf("type = %q", a.Type)
}
if a.Engine != EngineJet || a.Rules != RulesIFR {
t.Errorf("engine/rules = %s/%s", a.Engine, a.Rules)
}
if a.Dep != "KBTV" || a.Arr != "KBOS" {
t.Errorf("dep/arr = %s/%s", a.Dep, a.Arr)
}
if a.CruiseAlt != 29000 {
t.Errorf("cruise = %d", a.CruiseAlt)
}
if a.Route != "BTV4 MPV LEB MHT" {
t.Errorf("route = %q", a.Route)
}
if a.Remarks != "/v/charts" {
t.Errorf("remarks = %q", a.Remarks)
}
if a.Squawk != "2200" || a.XPDRMode != XPDRModeStandby {
t.Errorf("sqk = %s mode = %s", a.Squawk, a.XPDRMode)
}
if a.Lat != 44.469758 || a.Lon != -73.154747 {
t.Errorf("pos = %v %v", a.Lat, a.Lon)
}
if a.Alt != 335 || a.Speed != 0 || a.Heading != 360 {
t.Errorf("alt/spd/hdg = %v/%v/%v", a.Alt, a.Speed, a.Heading)
}
// Second: turboprop IFR
if rows[1].Callsign != "USA456" || rows[1].Engine != EngineTurboprop {
t.Errorf("row1 = %+v", rows[1])
}
// Third: piston VFR
if rows[2].Callsign != "N4729H" || rows[2].Engine != EnginePiston || rows[2].Rules != RulesVFR {
t.Errorf("row2 = %+v", rows[2])
}
if rows[2].Squawk != "1200" {
t.Errorf("VFR squawk = %s", rows[2].Squawk)
}
}
func TestParseAIR_CommentsBlankCRLF(t *testing.T) {
text := "; header\r\n\r\nAAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\r\n"
rows, errs := ParseAIR(text)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if len(rows) != 1 || rows[0].Callsign != "AAL1" {
t.Fatalf("rows: %+v", rows)
}
if rows[0].XPDRMode != XPDRModeNormal {
t.Errorf("mode = %s", rows[0].XPDRMode)
}
}
func TestParseAIR_TooFewFields(t *testing.T) {
_, errs := ParseAIR("AAL1:B738:J:I:KBTV\n")
if len(errs) != 1 || !strings.Contains(errs[0], "Invalid number of fields") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_DuplicateCallsign(t *testing.T) {
line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90"
text := line + "\n" + line + "\n"
rows, errs := ParseAIR(text)
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1 (first kept)", len(rows))
}
if len(errs) != 1 || !strings.Contains(errs[0], "Duplicate callsign") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_InvalidEngine(t *testing.T) {
line := "AAL1:B738:X:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n"
_, errs := ParseAIR(line)
if len(errs) != 1 || !strings.Contains(errs[0], "engine type") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_InvalidRules(t *testing.T) {
line := "AAL1:B738:J:Z:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n"
_, errs := ParseAIR(line)
if len(errs) != 1 || !strings.Contains(errs[0], "flight plan type") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_InvalidXPDR(t *testing.T) {
line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:X:44.0:-73.0:335:0:90\n"
_, errs := ParseAIR(line)
if len(errs) != 1 || !strings.Contains(errs[0], "transponder mode") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_InvalidNumeric(t *testing.T) {
tests := []struct {
name string
line string
}{
{"cruise", "AAL1:B738:J:I:KBTV:KBOS:abc:DCT::2200:N:44.0:-73.0:335:0:90"},
{"lat", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:xx:-73.0:335:0:90"},
{"lon", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:yy:335:0:90"},
{"alt", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:zz:0:90"},
{"spd", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:no:90"},
{"hdg", "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:no"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, errs := ParseAIR(tt.line + "\n")
if len(errs) != 1 || !strings.Contains(errs[0], "Invalid numeric field") {
t.Fatalf("errs: %v", errs)
}
})
}
}
func TestParseAIR_MissingCallsign(t *testing.T) {
line := ":B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90\n"
_, errs := ParseAIR(line)
if len(errs) != 1 || !strings.Contains(errs[0], "Missing callsign") {
t.Fatalf("errs: %v", errs)
}
}
func TestParseAIR_AllEngineAndRules(t *testing.T) {
// P/T/J/H and V/I/D/S
mk := func(cs, eng, rules string) string {
return cs + ":C172:" + eng + ":" + rules + ":KBTV:KBOS:5000:DCT::1200:N:44.0:-73.0:335:0:90"
}
text := strings.Join([]string{
mk("A1", "P", "V"),
mk("A2", "T", "I"),
mk("A3", "J", "D"),
mk("A4", "H", "S"),
mk("a5", "p", "v"), // case fold
}, "\n")
rows, errs := ParseAIR(text)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if len(rows) != 5 {
t.Fatalf("rows = %d", len(rows))
}
if rows[4].Callsign != "A5" || rows[4].Engine != "P" || rows[4].Rules != "V" {
t.Errorf("case fold: %+v", rows[4])
}
}
func TestParseAIR_BestEffortPartialLoad(t *testing.T) {
text := strings.Join([]string{
"GOOD1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90",
"BAD:too:few",
"GOOD2:C172:P:V:KBTV:KLEB:5000:DCT::1200:S:44.1:-73.1:335:0:180",
"GOOD1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90", // dup
}, "\n")
rows, errs := ParseAIR(text)
if len(rows) != 2 {
t.Fatalf("rows = %d, want 2: %+v", len(rows), rows)
}
if len(errs) != 2 {
t.Fatalf("errs = %v, want 2", errs)
}
if rows[0].Callsign != "GOOD1" || rows[1].Callsign != "GOOD2" {
t.Errorf("callsigns: %s %s", rows[0].Callsign, rows[1].Callsign)
}
}
func TestParseAIR_CruiseAltFloatTrunc(t *testing.T) {
// python: int(float(f[6]))
line := "AAL1:B738:J:I:KBTV:KBOS:29000.9:DCT::2200:N:44.0:-73.0:335:0:90\n"
rows, errs := ParseAIR(line)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if rows[0].CruiseAlt != 29000 {
t.Errorf("cruise = %d, want 29000", rows[0].CruiseAlt)
}
}
func TestParseAIR_ExtraFieldsIgnored(t *testing.T) {
// More than 16 fields: we only use first 16 (split keeps extras in later indices;
// f[15] is still heading if no colons in heading — extra colons shift fields).
// With trailing extra colon-fields, heading is still f[15] if we have ≥16.
line := "AAL1:B738:J:I:KBTV:KBOS:10000:DCT::2200:N:44.0:-73.0:335:0:90:EXTRA:MORE\n"
rows, errs := ParseAIR(line)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if len(rows) != 1 || rows[0].Heading != 90 {
t.Fatalf("rows: %+v", rows)
}
}

437
internal/sweatbox/apt.go Normal file
View File

@@ -0,0 +1,437 @@
package sweatbox
import (
"fmt"
"strconv"
"strings"
)
// Default airport header values (TWRTrainer fallbacks).
const (
defaultPatternSize = 1.0
defaultInitClimbProps = 3000.0
defaultInitClimbJets = 5000.0
defaultRegistration = "N"
)
// ParseAPT parses TWRTrainer-compatible .apt text.
// The returned Airport is populated with whatever could be read; errs lists
// validation and format issues (non-fatal — callers decide hard-fail policy).
func ParseAPT(text string) (Airport, []string) {
apt := Airport{
PatternSize: defaultPatternSize,
InitClimbProps: defaultInitClimbProps,
InitClimbJets: defaultInitClimbJets,
Registration: defaultRegistration,
}
var errs []string
var cur *Surface
// Track surface keys for duplicate detection: "KIND:NAME".
seen := make(map[string]int) // key → first line number
lines := strings.Split(text, "\n")
for i, raw := range lines {
lineno := i + 1
line := strings.TrimSpace(raw)
// Strip CR from CRLF.
line = strings.TrimSuffix(line, "\r")
if line == "" || strings.HasPrefix(line, ";") {
continue
}
low := strings.ToLower(line)
// Header keys.
if strings.HasPrefix(low, "icao=") {
apt.ICAO = strings.ToUpper(strings.TrimSpace(line[len("icao="):]))
if len(apt.ICAO) != 4 {
errs = append(errs, "ICAO code must be exactly 4 characters.")
}
continue
}
if strings.HasPrefix(low, "magnetic variation=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid magnetic variation on line %d", lineno))
continue
}
apt.MagVar = v
continue
}
if strings.HasPrefix(low, "field elevation=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid field elevation on line %d", lineno))
continue
}
apt.FieldElev = v
continue
}
if strings.HasPrefix(low, "pattern elevation=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid pattern elevation on line %d", lineno))
continue
}
apt.PatternElev = v
continue
}
if strings.HasPrefix(low, "pattern size=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid pattern size on line %d", lineno))
continue
}
apt.PatternSize = v
continue
}
if strings.HasPrefix(low, "initial climb props=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid initial climb props on line %d", lineno))
continue
}
apt.InitClimbProps = v
continue
}
if strings.HasPrefix(low, "initial climb jets=") {
v, err := parseFloatField(line)
if err != nil {
errs = append(errs, fmt.Sprintf("Invalid initial climb jets on line %d", lineno))
continue
}
apt.InitClimbJets = v
continue
}
if strings.HasPrefix(low, "jet airlines=") {
// Preserve original case of prefixes after '=' (sample is uppercase).
idx := strings.Index(line, "=")
apt.JetAirlines = strings.TrimSpace(line[idx+1:])
continue
}
if strings.HasPrefix(low, "turboprop airlines=") {
idx := strings.Index(line, "=")
apt.TurboAirlines = strings.TrimSpace(line[idx+1:])
continue
}
if strings.HasPrefix(low, "registration=") {
idx := strings.Index(line, "=")
apt.Registration = strings.TrimSpace(line[idx+1:])
continue
}
// Runway-only options (must appear after a RUNWAY section header).
if strings.HasPrefix(low, "turnoff=") && cur != nil && cur.Kind == SurfaceRunway {
val := strings.ToLower(strings.TrimSpace(line[len("turnoff="):]))
cur.TurnoffLeft = val == "left"
continue
}
if da, db, ok := parseDisplacedThreshold(line); ok {
if cur != nil && cur.Kind == SurfaceRunway {
cur.DispA = da
cur.DispB = db
} else {
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
}
continue
}
// Section headers.
if name, ok := matchSection(line, "PARKING"); ok {
// PARKING names: \w+
if !isWordName(name) {
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
cur = nil
continue
}
s := Surface{Kind: SurfaceParking, Name: strings.ToUpper(name)}
key := SurfaceParking + ":" + s.Name
if first, dup := seen[key]; dup {
errs = append(errs, fmt.Sprintf("Duplicate parking %s (first on line %d, again on line %d)", s.Name, first, lineno))
} else {
seen[key] = lineno
}
apt.Surfaces = append(apt.Surfaces, s)
cur = &apt.Surfaces[len(apt.Surfaces)-1]
continue
}
if a, b, ok := matchRunway(line); ok {
s := Surface{
Kind: SurfaceRunway,
Name: strings.ToUpper(a) + "/" + strings.ToUpper(b),
RwyA: strings.ToUpper(a),
RwyB: strings.ToUpper(b),
TurnoffLeft: true, // default
}
key := SurfaceRunway + ":" + s.Name
if first, dup := seen[key]; dup {
errs = append(errs, fmt.Sprintf("Duplicate runway %s (first on line %d, again on line %d)", s.Name, first, lineno))
} else {
seen[key] = lineno
}
apt.Surfaces = append(apt.Surfaces, s)
cur = &apt.Surfaces[len(apt.Surfaces)-1]
continue
}
if name, ok := matchSection(line, "TAXIWAY"); ok {
if !isTaxiHoldName(name) {
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
cur = nil
continue
}
s := Surface{Kind: SurfaceTaxiway, Name: strings.ToUpper(name)}
key := SurfaceTaxiway + ":" + s.Name
if first, dup := seen[key]; dup {
errs = append(errs, fmt.Sprintf("Duplicate taxiway %s (first on line %d, again on line %d)", s.Name, first, lineno))
} else {
seen[key] = lineno
}
apt.Surfaces = append(apt.Surfaces, s)
cur = &apt.Surfaces[len(apt.Surfaces)-1]
continue
}
if name, ok := matchSection(line, "HOLD"); ok {
if !isTaxiHoldName(name) {
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
cur = nil
continue
}
s := Surface{Kind: SurfaceHold, Name: strings.ToUpper(name)}
key := SurfaceHold + ":" + s.Name
if first, dup := seen[key]; dup {
errs = append(errs, fmt.Sprintf("Duplicate hold %s (first on line %d, again on line %d)", s.Name, first, lineno))
} else {
seen[key] = lineno
}
apt.Surfaces = append(apt.Surfaces, s)
cur = &apt.Surfaces[len(apt.Surfaces)-1]
continue
}
// Waypoint: "lat lon" with required decimal point (TWRTrainer regex).
if lat, lon, ok := parsePoint(line); ok {
if cur == nil {
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
continue
}
cur.Points = append(cur.Points, Point{Lat: lat, Lon: lon})
continue
}
errs = append(errs, fmt.Sprintf("Unknown line format found on line %d", lineno))
}
// Post-validation: waypoint counts.
for _, s := range apt.Surfaces {
switch s.Kind {
case SurfaceParking:
if len(s.Points) != 1 {
errs = append(errs, fmt.Sprintf("Parking area %s has no waypoint defined.", s.Name))
}
case SurfaceRunway:
if len(s.Points) < 2 {
errs = append(errs, fmt.Sprintf("Runway %s does not have at least two waypoints defined.", s.Name))
}
case SurfaceHold:
if len(s.Points) != 1 {
errs = append(errs, fmt.Sprintf("Hold %s has no waypoint defined.", s.Name))
}
case SurfaceTaxiway:
if len(s.Points) < 2 {
errs = append(errs, fmt.Sprintf("Taxiway %s does not have at least two waypoints defined.", s.Name))
}
}
}
return apt, errs
}
// parseFloatField extracts the value after a key= prefix.
func parseFloatField(line string) (float64, error) {
idx := strings.Index(line, "=")
if idx < 0 {
return 0, fmt.Errorf("missing =")
}
return strconv.ParseFloat(strings.TrimSpace(line[idx+1:]), 64)
}
// parseDisplacedThreshold matches "displaced threshold=a/b".
func parseDisplacedThreshold(line string) (float64, float64, bool) {
low := strings.ToLower(line)
const prefix = "displaced threshold="
if !strings.HasPrefix(low, prefix) {
return 0, 0, false
}
rest := strings.TrimSpace(line[len(prefix):])
// Allow original-case prefix length: find '=' then rest.
if i := strings.Index(line, "="); i >= 0 {
rest = strings.TrimSpace(line[i+1:])
}
parts := strings.Split(rest, "/")
if len(parts) != 2 {
return 0, 0, false
}
a, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
b, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
return a, b, true
}
// matchSection matches "[KIND name]" case-insensitively and returns the name.
func matchSection(line, kind string) (name string, ok bool) {
if len(line) < 3 || line[0] != '[' || line[len(line)-1] != ']' {
return "", false
}
inner := line[1 : len(line)-1]
parts := strings.Fields(inner)
if len(parts) != 2 {
return "", false
}
if !strings.EqualFold(parts[0], kind) {
return "", false
}
return parts[1], true
}
// matchRunway matches [RUNWAY a/b] with TWRTrainer runway designators.
func matchRunway(line string) (a, b string, ok bool) {
if len(line) < 3 || line[0] != '[' || line[len(line)-1] != ']' {
return "", "", false
}
inner := line[1 : len(line)-1]
parts := strings.Fields(inner)
if len(parts) != 2 {
return "", "", false
}
if !strings.EqualFold(parts[0], "RUNWAY") {
return "", "", false
}
ends := strings.Split(parts[1], "/")
if len(ends) != 2 {
return "", "", false
}
if !isRunwayDesignator(ends[0]) || !isRunwayDesignator(ends[1]) {
return "", "", false
}
return ends[0], ends[1], true
}
// isRunwayDesignator matches (?:[1-2]\d|3[0-6]|[1-9])[LRC]?
func isRunwayDesignator(s string) bool {
if s == "" {
return false
}
s = strings.ToUpper(s)
// Optional L/R/C suffix.
suf := byte(0)
last := s[len(s)-1]
if last == 'L' || last == 'R' || last == 'C' {
suf = last
s = s[:len(s)-1]
}
if s == "" {
return false
}
// Parse number 136, no leading zero (except we don't allow 0).
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 36 {
return false
}
// Reject leading zeros: "01" etc. strconv accepts them but TWRTrainer does not.
if len(s) > 1 && s[0] == '0' {
return false
}
// Single digit 1-9 OK; 10-36 OK; with optional LRC.
_ = suf
return true
}
// isWordName matches \w+ (ASCII letters, digits, underscore).
func isWordName(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
continue
}
return false
}
return true
}
// isTaxiHoldName matches [A-Z]+\d* (letters then optional digits), case-insensitive.
func isTaxiHoldName(s string) bool {
if s == "" {
return false
}
i := 0
for i < len(s) {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
i++
continue
}
break
}
if i == 0 {
return false
}
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
return false
}
}
return true
}
// parsePoint matches ^(\-?\d+\.\d+) (\-?\d+\.\d+)$ — decimal point required.
func parsePoint(line string) (lat, lon float64, ok bool) {
parts := strings.Fields(line)
if len(parts) != 2 {
return 0, 0, false
}
if !hasDecimalPoint(parts[0]) || !hasDecimalPoint(parts[1]) {
return 0, 0, false
}
// hasDecimalPoint guarantees a ParseFloat-able digit form.
lat, _ = strconv.ParseFloat(parts[0], 64)
lon, _ = strconv.ParseFloat(parts[1], 64)
return lat, lon, true
}
func hasDecimalPoint(s string) bool {
// Must contain '.' and at least one digit on each side (optional leading '-').
if s == "" {
return false
}
start := 0
if s[0] == '-' {
start = 1
}
dot := strings.IndexByte(s[start:], '.')
if dot < 0 {
return false
}
// digit(s) before and after the dot
before := s[start : start+dot]
after := s[start+dot+1:]
if before == "" || after == "" {
return false
}
for i := 0; i < len(before); i++ {
if before[i] < '0' || before[i] > '9' {
return false
}
}
for i := 0; i < len(after); i++ {
if after[i] < '0' || after[i] > '9' {
return false
}
}
return true
}

View File

@@ -0,0 +1,576 @@
package sweatbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseAPT_KBTVFixture(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "KBTV_example.apt"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
apt, errs := ParseAPT(string(data))
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if apt.ICAO != "KBTV" {
t.Errorf("ICAO = %q, want KBTV", apt.ICAO)
}
if apt.MagVar != 16 {
t.Errorf("MagVar = %v, want 16", apt.MagVar)
}
if apt.FieldElev != 335 {
t.Errorf("FieldElev = %v, want 335", apt.FieldElev)
}
if apt.PatternElev != 1335 {
t.Errorf("PatternElev = %v, want 1335", apt.PatternElev)
}
if apt.PatternSize != 1 {
t.Errorf("PatternSize = %v, want 1", apt.PatternSize)
}
if apt.InitClimbProps != 10000 {
t.Errorf("InitClimbProps = %v, want 10000", apt.InitClimbProps)
}
if apt.InitClimbJets != 10000 {
t.Errorf("InitClimbJets = %v, want 10000", apt.InitClimbJets)
}
if apt.Registration != "N" {
t.Errorf("Registration = %q, want N", apt.Registration)
}
if !strings.Contains(apt.JetAirlines, "AAL") {
t.Errorf("JetAirlines missing AAL: %q", apt.JetAirlines)
}
if !strings.Contains(apt.TurboAirlines, "EGF") {
t.Errorf("TurboAirlines missing EGF: %q", apt.TurboAirlines)
}
// Count surface kinds.
var nPark, nRwy, nTaxi, nHold int
for _, s := range apt.Surfaces {
switch s.Kind {
case SurfaceParking:
nPark++
if len(s.Points) != 1 {
t.Errorf("parking %s: %d points", s.Name, len(s.Points))
}
case SurfaceRunway:
nRwy++
if len(s.Points) < 2 {
t.Errorf("runway %s: %d points", s.Name, len(s.Points))
}
case SurfaceTaxiway:
nTaxi++
case SurfaceHold:
nHold++
}
}
if nPark != 16 {
t.Errorf("parking count = %d, want 16", nPark)
}
if nRwy != 2 {
t.Errorf("runway count = %d, want 2", nRwy)
}
if nTaxi != 12 {
t.Errorf("taxiway count = %d, want 12", nTaxi)
}
if nHold != 0 {
t.Errorf("hold count = %d, want 0", nHold)
}
// Runway 19/1 options.
rwy := apt.FindSurface("19")
if rwy == nil {
t.Fatal("FindSurface(19) = nil")
}
if rwy.Name != "19/1" || rwy.RwyA != "19" || rwy.RwyB != "1" {
t.Errorf("runway 19/1 fields: %+v", rwy)
}
if rwy.DispA != 0 || rwy.DispB != 225 {
t.Errorf("displaced threshold = %v/%v, want 0/225", rwy.DispA, rwy.DispB)
}
if rwy.TurnoffLeft {
t.Error("turnoff=right expected TurnoffLeft=false")
}
rwy2 := apt.FindSurface("33/15")
if rwy2 == nil {
t.Fatal("FindSurface(33/15) = nil")
}
if rwy2.DispA != 500 || rwy2.DispB != 0 {
t.Errorf("33/15 disp = %v/%v, want 500/0", rwy2.DispA, rwy2.DispB)
}
if !rwy2.TurnoffLeft {
t.Error("turnoff=left expected TurnoffLeft=true")
}
// Parking lookup case-insensitive.
g1 := apt.FindSurface("g1")
if g1 == nil || g1.Kind != SurfaceParking {
t.Fatalf("FindSurface(g1) = %+v", g1)
}
if g1.Points[0].Lat < 44.46 || g1.Points[0].Lon > -73.15 {
t.Errorf("G1 point unexpected: %+v", g1.Points[0])
}
// Taxiway.
tw := apt.FindSurface("A")
if tw == nil || tw.Kind != SurfaceTaxiway || len(tw.Points) < 2 {
t.Fatalf("taxiway A: %+v", tw)
}
}
func TestParseAPT_Defaults(t *testing.T) {
apt, errs := ParseAPT("icao=KXYZ\n")
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if apt.PatternSize != defaultPatternSize {
t.Errorf("PatternSize default = %v", apt.PatternSize)
}
if apt.InitClimbProps != defaultInitClimbProps {
t.Errorf("InitClimbProps default = %v", apt.InitClimbProps)
}
if apt.InitClimbJets != defaultInitClimbJets {
t.Errorf("InitClimbJets default = %v", apt.InitClimbJets)
}
if apt.Registration != defaultRegistration {
t.Errorf("Registration default = %q", apt.Registration)
}
}
func TestParseAPT_InvalidICAO(t *testing.T) {
_, errs := ParseAPT("icao=BTV\n")
if len(errs) == 0 {
t.Fatal("expected ICAO length error")
}
if !strings.Contains(errs[0], "ICAO") {
t.Errorf("err = %q", errs[0])
}
}
func TestParseAPT_CommentsAndBlankAndCRLF(t *testing.T) {
text := "; comment\r\n\r\nicao=KBTV\r\nmagnetic variation=16\r\n"
apt, errs := ParseAPT(text)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if apt.ICAO != "KBTV" || apt.MagVar != 16 {
t.Errorf("got %+v", apt)
}
}
func TestParseAPT_InvalidNumericHeaders(t *testing.T) {
text := strings.Join([]string{
"icao=KBTV",
"magnetic variation=abc",
"field elevation=xyz",
"pattern elevation=?",
"pattern size=nope",
"initial climb props=bad",
"initial climb jets=bad",
}, "\n")
_, errs := ParseAPT(text)
if len(errs) < 6 {
t.Fatalf("want ≥6 numeric errors, got %v", errs)
}
}
func TestParseAPT_ParkingWaypointCounts(t *testing.T) {
text := `
icao=KBTV
[PARKING G1]
; no waypoint
[PARKING G2]
44.1 -73.1
44.2 -73.2
`
_, errs := ParseAPT(text)
// G1: 0 points, G2: 2 points — both wrong
found := 0
for _, e := range errs {
if strings.Contains(e, "Parking area") {
found++
}
}
if found != 2 {
t.Fatalf("want 2 parking errors, got %v", errs)
}
}
func TestParseAPT_RunwayValidation(t *testing.T) {
text := `
icao=KBTV
[RUNWAY 19/1]
44.1 -73.1
[RUNWAY 99/1]
[RUNWAY 22/04]
[RUNWAY 15L/33R]
44.0 -73.0
44.1 -73.1
`
apt, errs := ParseAPT(text)
// 19/1 has only 1 point → error
// 99/1 invalid designator → unknown line
// 22/04 leading zero on B → unknown
// 15L/33R valid with 2 points → ok
var hasRwyPts, hasUnknown bool
for _, e := range errs {
if strings.Contains(e, "Runway 19/1") {
hasRwyPts = true
}
if strings.Contains(e, "Unknown line") {
hasUnknown = true
}
}
if !hasRwyPts {
t.Errorf("missing runway waypoint error: %v", errs)
}
if !hasUnknown {
t.Errorf("missing unknown line for bad runway: %v", errs)
}
if apt.FindSurface("15L") == nil {
t.Error("expected 15L/33R to parse")
}
}
func TestParseAPT_TaxiwayAndHold(t *testing.T) {
text := `
icao=KBTV
[TAXIWAY A]
44.1 -73.1
[TAXIWAY B1]
44.0 -73.0
44.1 -73.1
[HOLD HS1]
44.5 -73.5
[HOLD HS2]
[TAXIWAY 1BAD]
[HOLD bad-name]
`
apt, errs := ParseAPT(text)
// A has 1 point → taxi error; HS2 no point → hold error; bad names → unknown
var taxiErr, holdErr, unknown int
for _, e := range errs {
switch {
case strings.Contains(e, "Taxiway A"):
taxiErr++
case strings.Contains(e, "Hold HS2"):
holdErr++
case strings.Contains(e, "Unknown line"):
unknown++
}
}
if taxiErr != 1 {
t.Errorf("taxi err count %d: %v", taxiErr, errs)
}
if holdErr != 1 {
t.Errorf("hold err count %d: %v", holdErr, errs)
}
if unknown < 2 {
t.Errorf("want ≥2 unknown (bad names), got %d: %v", unknown, errs)
}
if apt.FindSurface("B1") == nil {
t.Error("B1 missing")
}
if apt.FindSurface("HS1") == nil {
t.Error("HS1 missing")
}
}
func TestParseAPT_Duplicates(t *testing.T) {
text := `
icao=KBTV
[PARKING G1]
44.1 -73.1
[PARKING G1]
44.2 -73.2
[TAXIWAY A]
44.0 -73.0
44.1 -73.1
[TAXIWAY A]
44.0 -73.0
44.1 -73.1
[RUNWAY 1/19]
44.0 -73.0
44.1 -73.1
[RUNWAY 1/19]
44.0 -73.0
44.1 -73.1
[HOLD H1]
44.5 -73.5
[HOLD H1]
44.6 -73.6
`
_, errs := ParseAPT(text)
var dups int
for _, e := range errs {
if strings.Contains(e, "Duplicate") {
dups++
}
}
if dups != 4 {
t.Fatalf("want 4 duplicate errors, got %d: %v", dups, errs)
}
}
func TestParseAPT_TurnoffAndDisplacedOutsideRunway(t *testing.T) {
text := `
icao=KBTV
turnoff=left
displaced threshold=100/200
[PARKING G1]
44.1 -73.1
turnoff=right
`
_, errs := ParseAPT(text)
// turnoff without runway, displaced without runway, turnoff after parking
if len(errs) < 2 {
t.Fatalf("want errors for orphaned runway options: %v", errs)
}
}
func TestParseAPT_UnknownAndOrphanPoint(t *testing.T) {
text := `
icao=KBTV
not a valid line
44.1 -73.1
`
_, errs := ParseAPT(text)
if len(errs) < 2 {
t.Fatalf("want ≥2 errors, got %v", errs)
}
}
func TestParseAPT_DisplacedThresholdFormats(t *testing.T) {
text := `
icao=KBTV
[RUNWAY 9/27]
displaced threshold=100/200
44.0 -73.0
44.1 -73.1
`
apt, errs := ParseAPT(text)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
r := apt.FindSurface("9")
if r == nil || r.DispA != 100 || r.DispB != 200 {
t.Fatalf("disp: %+v", r)
}
}
func TestParseAPT_CaseInsensitiveHeaders(t *testing.T) {
text := `
ICAO=kbtv
Magnetic Variation=14.5
Field Elevation=100
Pattern Elevation=1100
Pattern Size=1.5
Initial Climb Props=2000
Initial Climb Jets=4000
Jet Airlines=UAL,DAL
Turboprop Airlines=SKW
Registration=N
[parking ga1]
44.1 -73.1
[runway 18/36]
turnoff=LEFT
44.0 -73.0
44.1 -73.1
[taxiway a]
44.0 -73.0
44.1 -73.1
[hold hs1]
44.2 -73.2
`
apt, errs := ParseAPT(text)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if apt.ICAO != "KBTV" {
t.Errorf("ICAO=%q", apt.ICAO)
}
if apt.MagVar != 14.5 || apt.FieldElev != 100 || apt.PatternElev != 1100 {
t.Errorf("headers: %+v", apt)
}
if apt.PatternSize != 1.5 || apt.InitClimbProps != 2000 || apt.InitClimbJets != 4000 {
t.Errorf("climbs: %+v", apt)
}
if apt.JetAirlines != "UAL,DAL" || apt.TurboAirlines != "SKW" {
t.Errorf("airlines jet=%q turbo=%q", apt.JetAirlines, apt.TurboAirlines)
}
if apt.FindSurface("GA1") == nil || apt.FindSurface("18") == nil {
t.Error("surfaces missing")
}
r := apt.FindSurface("18")
if r == nil || !r.TurnoffLeft {
t.Errorf("turnoff left: %+v", r)
}
}
func TestFindSurface_NilAndMiss(t *testing.T) {
var a *Airport
if a.FindSurface("X") != nil {
t.Error("nil airport should return nil")
}
apt := Airport{Surfaces: []Surface{{Kind: SurfaceParking, Name: "G1"}}}
if apt.FindSurface("NOPE") != nil {
t.Error("miss should be nil")
}
}
func TestParseAPT_InvalidDisplacedThreshold(t *testing.T) {
// parseDisplacedThreshold returns false for bad format → unknown line if no match.
// With a runway current, malformed displaced that doesn't parse as displaced falls through.
text := `
icao=KBTV
[RUNWAY 1/19]
displaced threshold=abc/def
44.0 -73.0
44.1 -73.1
`
_, errs := ParseAPT(text)
// "displaced threshold=abc/def" fails parseDisplacedThreshold → unknown line
found := false
for _, e := range errs {
if strings.Contains(e, "Unknown line") {
found = true
}
}
if !found {
t.Fatalf("want unknown line for bad displaced: %v", errs)
}
}
func TestIsRunwayDesignator(t *testing.T) {
valid := []string{"1", "9", "10", "18", "27", "36", "15L", "33R", "9C", "36L"}
for _, s := range valid {
if !isRunwayDesignator(s) {
t.Errorf("%q should be valid", s)
}
}
invalid := []string{"", "0", "37", "01", "100", "L", "1X", "22/4"}
for _, s := range invalid {
if isRunwayDesignator(s) {
t.Errorf("%q should be invalid", s)
}
}
}
func TestParsePoint(t *testing.T) {
lat, lon, ok := parsePoint("44.1 -73.2")
if !ok || lat != 44.1 || lon != -73.2 {
t.Fatalf("got %v %v %v", lat, lon, ok)
}
// Integers without decimal rejected.
if _, _, ok := parsePoint("44 -73"); ok {
t.Error("integers should fail")
}
if _, _, ok := parsePoint("44.1"); ok {
t.Error("single field should fail")
}
if _, _, ok := parsePoint("-.1 -73.0"); ok {
t.Error("missing digits before dot")
}
if _, _, ok := parsePoint("44. -73.0"); ok {
t.Error("missing digits after dot")
}
if _, _, ok := parsePoint("4a.1 -73.0"); ok {
t.Error("non-digit before dot")
}
if _, _, ok := parsePoint("44.1b -73.0"); ok {
t.Error("non-digit after dot")
}
if _, _, ok := parsePoint(""); ok {
t.Error("empty should fail")
}
}
func TestParseAPT_InvalidParkingName(t *testing.T) {
// Name with punctuation fails isWordName after matchSection.
text := "icao=KBTV\n[PARKING G-1]\n44.1 -73.1\n"
_, errs := ParseAPT(text)
found := false
for _, e := range errs {
if strings.Contains(e, "Unknown line") {
found = true
}
}
if !found {
t.Fatalf("want unknown for bad parking name: %v", errs)
}
}
func TestHelperEdgeCases(t *testing.T) {
// parseFloatField missing '='
if _, err := parseFloatField("noequals"); err == nil {
t.Error("expected missing = error")
}
// parseDisplacedThreshold: not a prefix; wrong slash count
if _, _, ok := parseDisplacedThreshold("nope"); ok {
t.Error("expected false for non-prefix")
}
if _, _, ok := parseDisplacedThreshold("displaced threshold=100"); ok {
t.Error("expected false for single value")
}
if _, _, ok := parseDisplacedThreshold("displaced threshold=1/2/3"); ok {
t.Error("expected false for three parts")
}
// matchSection edge cases
if _, ok := matchSection("x", "PARKING"); ok {
t.Error("short line")
}
if _, ok := matchSection("[PARKING]", "PARKING"); ok {
t.Error("one field only")
}
if _, ok := matchSection("[OTHER X]", "PARKING"); ok {
t.Error("wrong kind")
}
if _, ok := matchSection("PARKING X]", "PARKING"); ok {
t.Error("missing open bracket")
}
// matchRunway edge cases
if _, _, ok := matchRunway("x"); ok {
t.Error("short")
}
if _, _, ok := matchRunway("[RUNWAY]"); ok {
t.Error("one field")
}
if _, _, ok := matchRunway("[TAXIWAY A]"); ok {
t.Error("not runway kind")
}
if _, _, ok := matchRunway("[RUNWAY 19]"); ok {
t.Error("no slash")
}
// isWordName / isTaxiHoldName
if isWordName("") {
t.Error("empty word")
}
if isWordName("G-1") {
t.Error("hyphen not word")
}
if isWordName("G_1") != true {
t.Error("underscore allowed")
}
if isTaxiHoldName("") {
t.Error("empty taxi")
}
if isTaxiHoldName("A1B") {
t.Error("letter after digit")
}
// hasDecimalPoint empty
if hasDecimalPoint("") {
t.Error("empty decimal")
}
if hasDecimalPoint("-") {
t.Error("bare minus")
}
}

View File

@@ -0,0 +1,20 @@
; 0 - callsign
; 1 - ac type
; 2 - engine type (P, T, J, H)
; 3 - fp type (V, I)
; 4 - dep apt
; 5 - arr apt
; 6 - crz alt
; 7 - route
; 8 - remarks
; 9 - squawk code
; 10 - normal or standby (N or S)
; 11 - lat
; 12 - lon
; 13 - alt
; 14 - ground speed
; 15 - hdg
AAL123:B738/F:J:I:KBTV:KBOS:29000:BTV4 MPV LEB MHT:/v/charts:2200:S:44.469758:-73.154747:335:0:360
USA456:B190/G:T:I:KBTV:KBOS:25000:DCT GPS:/v/vfr/charts:2201:S:44.468802:-73.154547:335:0:360
N4729H:C172/A:P:V:KBTV:KLEB:7500:MPV LEB:/v/vfr:1200:S:44.463310:-73.152026:335:0:360

View File

@@ -0,0 +1,158 @@
icao=KBTV
magnetic variation=16
field elevation=335
pattern elevation=1335
pattern size=1
initial climb props=10000
initial climb jets=10000
jet airlines=AAL,ACA,AWE,BAW,BLR,BTA,CAL,CAX,COA,CVA,DAL,DLH,EAL,EIN,FDX,FFT,JAL,JBU,KAL,KLM,ML,NEA,NWA,QFA,SAC,SAS,SWA,UAL,UPS,USA,WWA
turboprop airlines=EGF,USA,COA,CJC,JZA
registration=N
[PARKING G1]
44.46893 -73.15392
[PARKING G2]
44.46829 -73.15361
[PARKING G3]
44.4679 -73.15348
[PARKING G4]
44.46365 -73.15263
[PARKING GA1]
44.4637 -73.15221
[PARKING GA2]
44.4639 -73.15272
[PARKING GA3]
44.46538 -73.15325
[PARKING GA4]
44.46539 -73.1538
[PARKING GA5]
44.466 -73.15393
[PARKING GA6]
44.46638 -73.15446
[PARKING GA7]
44.46404 -73.14126
[PARKING GA8]
44.46419 -73.14151
[PARKING GA9]
44.46437 -73.1418
[PARKING CARGO1]
44.46456 -73.14812
[PARKING CARGO2]
44.46459 -73.14736
[PARKING CARGO3]
44.46467 -73.14666
[RUNWAY 19/1]
displaced threshold=0/225
turnoff=right
44.4734 -73.15302
44.47263 -73.15286
44.47011 -73.15233
44.4684 -73.15197
44.46524 -73.1513
44.46434 -73.15111
[RUNWAY 33/15]
displaced threshold=500/0
turnoff=left
44.46571 -73.14166
44.46598 -73.14211
44.47044 -73.14932
44.47263 -73.15287
44.47329 -73.15394
44.47614 -73.15855
44.47953 -73.16403
44.48049 -73.16559
[TAXIWAY A]
44.46512 -73.15227
44.46584 -73.15242
44.4669 -73.15264
44.46765 -73.15279
44.46826 -73.15292
44.46903 -73.15308
44.46991 -73.15326
44.47079 -73.15344
44.4733 -73.15396
44.47339 -73.15301
[TAXIWAY B]
44.46826 -73.15292
44.46843 -73.15198
44.47044 -73.14933
[TAXIWAY C]
44.46512 -73.15227
44.46525 -73.1513
44.46548 -73.1482
44.46559 -73.14682
44.46585 -73.14342
44.46597 -73.1421
[TAXIWAY D]
44.46572 -73.14168
44.4666 -73.14155
44.46688 -73.14165
44.4705 -73.14531
44.47074 -73.14564
44.47166 -73.14718
44.47206 -73.14785
44.47264 -73.14881
44.47315 -73.14964
44.47345 -73.15014
44.47372 -73.15059
[TAXIWAY E]
44.47372 -73.15059
44.47341 -73.15302
[TAXIWAY F]
44.47371 -73.15058
44.48098 -73.16257
44.48108 -73.16299
44.4811 -73.16485
44.48049 -73.16558
[TAXIWAY G]
44.48049 -73.16558
44.47919 -73.16704
44.47823 -73.16551
44.47483 -73.16
44.47079 -73.15343
44.4701 -73.15231
[TAXIWAY H]
44.47484 -73.16002
44.47615 -73.15855
[TAXIWAY J]
44.4657 -73.14165
44.46486 -73.14167
44.46446 -73.14171
[TAXIWAY K]
44.46484 -73.14167
44.46584 -73.14339
[TAXIWAY L]
44.46433 -73.15113
44.46423 -73.15207
[TAXIWAY M]
44.47823 -73.16553
44.47953 -73.16402

115
internal/sweatbox/types.go Normal file
View File

@@ -0,0 +1,115 @@
// Package sweatbox implements the pure sweatbox simulator domain:
// airport/scenario file parsing, taxi graph, aircraft state, and tick kinematics.
//
// Allowed imports: standard library and internal/geo only.
// Must not import session, postoffice, server, web, db, auth, metar, fsdclient, or protocol.
package sweatbox
import "strings"
// Surface kind identifiers (TWRTrainer section types).
const (
SurfaceParking = "PARKING"
SurfaceRunway = "RUNWAY"
SurfaceTaxiway = "TAXIWAY"
SurfaceHold = "HOLD"
)
// Engine type codes used in .air files.
const (
EnginePiston = "P"
EngineTurboprop = "T"
EngineJet = "J"
EngineHelicopter = "H"
)
// Flight-plan / rules codes used in .air files.
const (
RulesVFR = "V"
RulesIFR = "I"
RulesDVFR = "D"
RulesSVFR = "S"
)
// Transponder mode codes used in .air files.
const (
XPDRModeNormal = "N"
XPDRModeStandby = "S"
)
// Point is a geographic coordinate in decimal degrees.
type Point struct {
Lat float64
Lon float64
}
// Surface is one airport geometry element: parking, runway, taxiway, or hold.
type Surface struct {
Kind string // PARKING, RUNWAY, TAXIWAY, HOLD
Name string // parking/taxi/hold name, or "A/B" for runways
Points []Point
// Runway-only fields.
RwyA string // first end designator (e.g. "19")
RwyB string // reciprocal end designator (e.g. "1")
DispA float64 // displaced threshold feet for end A
DispB float64 // displaced threshold feet for end B
TurnoffLeft bool // true = left turnoff for end A (default true)
}
// Airport is a parsed .apt file: header metadata plus ordered surfaces.
type Airport struct {
ICAO string
MagVar float64 // magnetic variation, degrees (east positive convention of source file)
FieldElev float64 // field elevation, feet MSL
PatternElev float64 // pattern altitude, feet MSL
PatternSize float64 // pattern size scale, NM (default 1)
InitClimbProps float64 // initial climb props, feet
InitClimbJets float64 // initial climb jets, feet
JetAirlines string // comma-separated callsign prefixes
TurboAirlines string // comma-separated callsign prefixes
Registration string // single-letter GA callsign prefix (e.g. "N")
Surfaces []Surface
}
// FindSurface looks up a surface by name (case-insensitive).
// For runways, matches either end designator or the combined "A/B" name.
func (a *Airport) FindSurface(name string) *Surface {
if a == nil {
return nil
}
want := strings.ToUpper(name)
for i := range a.Surfaces {
s := &a.Surfaces[i]
if s.Kind == SurfaceRunway {
if s.RwyA == want || s.RwyB == want || s.Name == want {
return s
}
continue
}
if strings.ToUpper(s.Name) == want {
return s
}
}
return nil
}
// Aircraft is one row from a .air scenario file (position/state snapshot).
type Aircraft struct {
Callsign string
Type string // ICAO type, may include equipment suffix (e.g. "B738/F")
Engine string // P, T, J, H
Rules string // V, I, D, S
Dep string
Arr string
CruiseAlt int
Route string
Remarks string
Squawk string
XPDRMode string // N or S
Lat float64
Lon float64
Alt float64 // feet
Speed float64 // knots
Heading float64 // degrees
}

View File

@@ -117,12 +117,13 @@ check_no_imports "internal/session" "${MODULE}/internal/session/..." \
# internal/geo — stdlib only
check_stdlib_only "internal/geo" "${MODULE}/internal/geo/..."
# internal/web — must not import session, postoffice, metar
# internal/web — must not import session, postoffice, metar, sweatbox
# (server is allowed only for service-HTTP DTOs; keep that coupling minimal)
check_no_imports "internal/web" "${MODULE}/internal/web/..." \
"${MODULE}/internal/session" \
"${MODULE}/internal/postoffice" \
"${MODULE}/internal/metar"
"${MODULE}/internal/metar" \
"${MODULE}/internal/sweatbox"
# internal/db — must not import server, session, web, fsdclient
check_no_imports "internal/db" "${MODULE}/internal/db/..." \
@@ -137,6 +138,19 @@ check_no_imports "internal/auth" "${MODULE}/internal/auth/..." \
"${MODULE}/internal/session" \
"${MODULE}/internal/web"
# internal/sweatbox — pure sim; no orchestration / session / client packages
# (may import internal/geo + stdlib only; protocol encode lives in server host)
check_no_imports "internal/sweatbox" "${MODULE}/internal/sweatbox/..." \
"${MODULE}/internal/server" \
"${MODULE}/internal/web" \
"${MODULE}/internal/postoffice" \
"${MODULE}/internal/session" \
"${MODULE}/internal/db" \
"${MODULE}/internal/auth" \
"${MODULE}/internal/metar" \
"${MODULE}/pkg/fsdclient" \
"${MODULE}/pkg/protocol"
if [[ "$failed" -ne 0 ]]; then
echo
echo "Import graph check FAILED. See AGENTS.md §2."