mirror of
https://github.com/renorris/openfsd
synced 2026-08-14 09:25:51 +08:00
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:
11
AGENTS.md
11
AGENTS.md
@@ -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
97
internal/sweatbox/air.go
Normal 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
|
||||
}
|
||||
222
internal/sweatbox/air_test.go
Normal file
222
internal/sweatbox/air_test.go
Normal 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
437
internal/sweatbox/apt.go
Normal 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 1–36, 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
|
||||
}
|
||||
576
internal/sweatbox/apt_test.go
Normal file
576
internal/sweatbox/apt_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
20
internal/sweatbox/testdata/KBTV_example.air
vendored
Normal file
20
internal/sweatbox/testdata/KBTV_example.air
vendored
Normal 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
|
||||
158
internal/sweatbox/testdata/KBTV_example.apt
vendored
Normal file
158
internal/sweatbox/testdata/KBTV_example.apt
vendored
Normal 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
115
internal/sweatbox/types.go
Normal 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
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user