refactor parser packages

This commit is contained in:
windyboy
2025-12-24 17:23:41 +08:00
parent dbde6524b3
commit b82b707a25
14 changed files with 227 additions and 176 deletions
@@ -0,0 +1,412 @@
package aviation
import (
"caatsm/internal/adapter/dto"
"caatsm/internal/domain"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
var (
otherPatterns = []*regexp.Regexp{navPattern,
remarkPattern,
selPattern,
pbnPattern,
eetPattern,
performancePattern,
regPattern,
reroutePattern}
// ErrHeaderParse indicates an invalid header section.
ErrHeaderParse = errors.New("invalid telegram header")
// ErrBodyParse indicates a failure matching the telegram body.
ErrBodyParse = errors.New("invalid telegram body")
)
type BodyParser struct {
body string
bodyPatterns map[string]BodyConfig
mu sync.Mutex
}
func NewBodyParser(body string) *BodyParser {
return &BodyParser{
bodyPatterns: bodyPatterns,
body: body,
}
}
func (parser *BodyParser) GetBodyPatterns() map[string]BodyConfig {
parser.mu.Lock()
defer parser.mu.Unlock()
copied := make(map[string]BodyConfig, len(parser.bodyPatterns))
for k, v := range parser.bodyPatterns {
copied[k] = v
}
return copied
}
func (parser *BodyParser) SetBodyPatterns(patterns map[string]BodyConfig) {
parser.mu.Lock()
defer parser.mu.Unlock()
parser.bodyPatterns = patterns
}
func (parser *BodyParser) Parse() (string, interface{}, error) {
parser.mu.Lock()
defer parser.mu.Unlock()
parser.body = strings.TrimSpace(parser.body)
category := findCategory(parser.body)
if category == "" {
return "", nil, fmt.Errorf("no category found in body text")
}
if patternConfig, exists := parser.bodyPatterns[category]; exists && patternConfig.Patterns != nil {
for _, p := range patternConfig.Patterns {
if data := extract(parser.body, p.Expression); data != nil {
return parser.createBodyData(data)
}
}
}
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
}
func findCategory(body string) string {
if match := categoryRegex.FindStringSubmatch(body); match != nil {
for i, name := range categoryRegex.SubexpNames() {
if i != 0 && name == "category" {
return match[i]
}
}
}
return ""
}
func extract(data string, exp *regexp.Regexp) map[string]string {
match := exp.FindStringSubmatch(data)
if len(match) > 0 {
return extractData(match, exp)
}
return nil
}
func extractData(match []string, re *regexp.Regexp) map[string]string {
data := make(map[string]string)
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
data[name] = strings.TrimSpace(match[i])
}
}
return data
}
func (parser *BodyParser) createBodyData(data map[string]string) (string, interface{}, error) {
switch category := data["category"]; category {
case CategoryArrival:
return category, &domain.ARR{
Category: data[Category],
AircraftID: data[FlightNumber],
SSRModeAndCode: data[SSR],
DepartureAirport: data[DepartureCode],
ArrivalAirport: data[ArrivalCode],
ArrivalTime: data[ArrivalTime],
}, nil
case CategoryDeparture:
return category, &domain.DEP{
Category: data[Category],
AircraftID: data[FlightNumber],
SSRModeAndCode: data[SSR],
DepartureAirport: data[DepartureCode],
DepartureTime: data[DepartureTime],
Destination: data[ArrivalCode],
}, nil
case CategoryCancellation:
return category, &domain.CNL{
Category: data[Category],
AircraftID: data[FlightNumber],
DepartureAirport: data[DepartureCode],
DestinationAirport: data[ArrivalCode],
}, nil
case CategoryDelay:
return category, &domain.DLA{
Category: data[Category],
AircraftID: data[FlightNumber],
DepartureAirport: data[DepartureCode],
NewDepartureTime: data[DepartureTime],
ArrivalAirport: data[ArrivalCode],
ArrivalTime: data[ArrivalTime],
}, nil
case CategoryFlightPlan:
otherData := parseOther(data[OtherInfo])
return category, &domain.FPL{
Category: data[Category],
FlightNumber: data[FlightNumber],
ReferenceData: data[ReferenceData],
AircraftID: data[AircraftID],
SSRModeAndCode: data[Surveillance],
FlightRulesAndType: data[Indicator],
CruisingSpeedAndLevel: data[Speed] + data[Level],
DepartureAirport: data[DepartureCode],
DepartureTime: data[DepartureTime],
Route: data[Route],
DestinationAndTotalTime: data[DestinationCode] + data[EstimatedTime],
AlternateAirport: data[AlternateAirport],
OtherInfo: data[OtherInfo],
Register: otherData[Register],
EstimatedArrivalTime: data[EstimatedTime],
PBN: otherData[PBN],
NavigationEquipment: otherData[NavigationEquipment],
EstimatedElapsedTime: otherData[EstimatedElapsedTime],
SELCALCode: otherData[SELCALCode],
PerformanceCategory: otherData[PerformanceCategory],
RerouteInformation: otherData[RerouteInformation],
Remarks: otherData[Remarks],
}, nil
default:
return category, nil, fmt.Errorf("invalid message type: %s", category)
}
}
func headerToParsedTelegram(header Header) dto.ParsedTelegram {
return dto.ParsedTelegram{
MessageID: header.MessageID,
DateTime: header.DateTime,
PriorityIndicator: header.PriorityIndicator,
PrimaryAddress: header.PrimaryAddress,
SecondaryAddresses: header.SecondaryAddresses,
Originator: header.Originator,
OriginatorDateTime: header.OriginatorDateTime,
Category: header.Category,
Body: header.Body,
Content: header.Content,
ReceivedAt: header.ReceivedAt,
ParsedAt: header.ParsedAt,
}
}
func Parse(rawText string) (*dto.ParsedTelegram, error) {
header, err := ParseHeader(rawText)
if err != nil {
msg := dto.NewParsedTelegram()
msg.Content = rawText
msg.Comments = err.Error()
msg.ErrorReason = err.Error()
msg.Status = dto.MessageStatusHeaderError
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
}
bodyParser := NewBodyParser(header.Body)
category, bodyData, bodyErr := bodyParser.Parse()
header.Category = category
header.ParsedAt = time.Now()
if bodyErr != nil {
parsed := headerToParsedTelegram(header)
parsed.Parsed = false
parsed.Comments = bodyErr.Error()
parsed.Status = dto.MessageStatusBodyError
parsed.ErrorReason = bodyErr.Error()
return &parsed, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr)
}
parsed := headerToParsedTelegram(header)
parsed.BodyData = bodyData
parsed.Parsed = true
parsed.Status = dto.MessageStatusParsed
parsed.Uuid = uuid.New().String()
return &parsed, nil
}
func cleanMessage(text string) string {
cleanedText := emptyLineRemove.ReplaceAllString(text, "")
cleanText := strings.ReplaceAll(cleanedText, "\n\n", "\n")
if match := bodyOnly.FindStringSubmatch(cleanText); len(match) > 1 {
bodyContent := match[2]
if bodyContent[len(bodyContent)-1] == '\n' {
return bodyContent[:len(bodyContent)-1]
}
return bodyContent
}
return ""
}
// ParseHeader parses only the header portion of the message and returns a lightweight struct
// with header fields and body content. It is used internally by the aviation parser.
type Header struct {
MessageID string
DateTime string
PriorityIndicator string
PrimaryAddress string
SecondaryAddresses string
Originator string
OriginatorDateTime string
Category string
Content string
Body string
ReceivedAt time.Time
ParsedAt time.Time
}
func ParseHeader(fullMessage string) (Header, error) {
log := zap.S()
cleaned := cleanMessage(fullMessage)
lines := strings.Split(cleaned, "\n")
if len(lines) < 3 {
log.Warnf("invalid message format: %s", fullMessage)
return Header{Content: fullMessage}, fmt.Errorf("invalid message format: %s", fullMessage)
}
_, messageID, dateTime, err := parseStartIndicator(lines[0])
if err != nil {
return Header{Content: fullMessage}, err
}
priorityIndicator, primaryAddress := parsePriorityAndPrimary(lines[1])
secondaryAddresses, originator, originatorDateTime, body := parseRemainingLines(lines[2:])
return Header{
MessageID: messageID,
DateTime: dateTime,
PriorityIndicator: priorityIndicator,
PrimaryAddress: primaryAddress,
SecondaryAddresses: secondaryAddresses,
Originator: originator,
OriginatorDateTime: originatorDateTime,
Content: fullMessage,
Body: body,
ReceivedAt: time.Now(),
}, nil
}
func parseStartIndicator(line string) (string, string, string, error) {
parts := strings.Fields(line)
if len(parts) >= 3 && strings.HasPrefix(parts[0], StartIndicatorPrefix) {
return parts[0], parts[1], parts[2], nil
}
zap.S().Warnf("invalid start indicator line format: %s", line)
return "", "", "", fmt.Errorf("invalid start indicator line format: %s", line)
}
func parsePriorityAndPrimary(line string) (string, string) {
parts := strings.Fields(line)
if len(parts) >= 2 {
return parts[0], parts[1]
}
zap.S().Warnf("invalid priority and primary address line format: %s", line)
return "", ""
}
func parseRemainingLines(lines []string) (string, string, string, string) {
var (
secondaryAddresses string
originator string
originatorDateTime string
bodyAndFooter strings.Builder
headerEnded bool
)
for _, line := range lines {
line = strings.TrimSpace(line)
if headerEnded {
bodyAndFooter.WriteString(line + "\n")
} else {
switch {
case line == EndHeaderMarker:
case strings.HasPrefix(line, "."):
// Validate if dot-prefixed line matches originator format: .ORIGINATOR_CODE YYMMDD
// Originator code should be uppercase letters, date/time should be digits
originatorInfo := strings.Fields(line[1:])
if len(originatorInfo) >= 2 {
// Check if first token is all uppercase letters and second is all digits
firstToken := originatorInfo[0]
secondToken := originatorInfo[1]
if isAllUppercaseLetters(firstToken) && isAllDigits(secondToken) {
originator = firstToken
originatorDateTime = secondToken
headerEnded = true
} else {
// Doesn't match originator format, treat as body content
headerEnded = true
bodyAndFooter.WriteString(line + "\n")
}
} else {
// Not enough tokens for originator format, treat as body content
headerEnded = true
bodyAndFooter.WriteString(line + "\n")
}
case strings.HasPrefix(line, BeginPartMarker) || strings.HasPrefix(line, "("):
headerEnded = true
if strings.Index(line, "NNNN") > 0 {
break
}
bodyAndFooter.WriteString(line + "\n")
default:
if o1, o2 := getOriginator(line); o1 != "" {
originatorDateTime = o1
originator = o2
} else {
secondaryAddresses = secondaryAddresses + " " + line
}
}
}
}
return secondaryAddresses, originator, originatorDateTime, bodyAndFooter.String()
}
func getOriginator(line string) (string, string) {
match := originator.FindStringSubmatch(line)
if len(match) >= 3 {
return match[1], match[2]
}
zap.S().Warnf("invalid originator line format: %s", line)
return "", ""
}
// isAllUppercaseLetters checks if a string contains only uppercase letters
func isAllUppercaseLetters(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
// isAllDigits checks if a string contains only digits
func isAllDigits(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func parseOther(text string) map[string]string {
data := make(map[string]string)
for _, re := range otherPatterns {
if match := re.FindStringSubmatch(text); len(match) > 0 {
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
data[name] = strings.TrimSpace(match[i])
}
}
}
}
return data
}
@@ -0,0 +1,136 @@
package aviation
import (
"testing"
)
// Sample messages for benchmarking
var (
benchARRMessage = `ZCZC TMQ2526 141605
FF ZBTJZPZX
141604 ZBACZQZX
(ARR-JAE7433/A0132-RKSI-ZBTJ1604)
NNNN`
benchDEPMessage = `ZCZC DEP5678 120915
DD KLAXZPZX
120914 KSFOZQZX
(DEP-ABC5678-A1234-ZBTJ1440-ZGGG)
NNNN`
benchCNLMessage = `ZCZC CNL9012 150631
FF ZBTJZPZX
(CNL-CCA9012-ZBTJ-ZGGG)
NNNN`
benchDLAMessage = `ZCZC DLA3456 150631
FF ZBTJZPZX
(DLA-CCA3456-A1234-ZBTJ1600-ZGGG0200)
NNNN`
benchFPLMessage = `ZCZC TMQ2617 142150
GG ZBTJZPZX
150551 ZBTJUOBK
(FPL-OKA2861-IS
-MA60/M-SHID/C
-ZBTJ0030
-K0420S0450 CG J1 FZ
-ZSYT0100 ZSQD ZYTL
-REG/B3710 SEL/ RMK/TCAS )
NNNN`
benchComplexFPLMessage = `ZCZC FPL7890 150631
FF ZBTJZPZX
(FPL-JAE7433-IS
-B744/H-SXIRPZJWY/S
-ZBTJ1755
-K0926S0920 CG A326 VYK W80 HUR B339 GM A575 MANSA/K0919S0980
-EDDF0948 EDDK
-EET/ZMUB0100 UNKL0236
REG/B2422 SEL/JLAD
NAV/RNAV1 RNAV5 RNP4
RMK/AGCS EQUIPPED)
NNNN`
)
// BenchmarkParseARR benchmarks parsing ARR messages
func BenchmarkParseARR(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchARRMessage)
}
}
// BenchmarkParseDEP benchmarks parsing DEP messages
func BenchmarkParseDEP(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchDEPMessage)
}
}
// BenchmarkParseCNL benchmarks parsing CNL messages
func BenchmarkParseCNL(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchCNLMessage)
}
}
// BenchmarkParseDLA benchmarks parsing DLA messages
func BenchmarkParseDLA(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchDLAMessage)
}
}
// BenchmarkParseFPL benchmarks parsing simple FPL messages
func BenchmarkParseFPL(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchFPLMessage)
}
}
// BenchmarkParseComplexFPL benchmarks parsing complex FPL messages with extensive route and metadata
func BenchmarkParseComplexFPL(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(benchComplexFPLMessage)
}
}
// BenchmarkParseHeader benchmarks header parsing only
func BenchmarkParseHeader(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseHeader(benchARRMessage)
}
}
// BenchmarkParseBody benchmarks body parsing only (ARR)
func BenchmarkParseBody(b *testing.B) {
body := `(ARR-JAE7433/A0132-RKSI-ZBTJ1604)`
parser := NewBodyParser(body)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = parser.Parse()
}
}
// BenchmarkParseMixed benchmarks parsing a mix of message types
func BenchmarkParseMixed(b *testing.B) {
messages := []string{
benchARRMessage,
benchDEPMessage,
benchCNLMessage,
benchDLAMessage,
benchFPLMessage,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
msg := messages[i%len(messages)]
_, _ = Parse(msg)
}
}
@@ -0,0 +1,421 @@
package aviation
import (
"caatsm/internal/domain"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Aviation Parser", func() {
Describe("ParseHeader", func() {
Context("with a real ARR context", func() {
message := `ZCZC TMQ2530 141614
GG ZBTJZXZX
141614 ZSHCZTZX
(ARR-CES5470-ZBTJ-ZSHC1614)
NNNN`
It("should get a clean body text", func() {
body := cleanMessage(message)
expected := `ZCZC TMQ2530 141614
GG ZBTJZXZX
141614 ZSHCZTZX
(ARR-CES5470-ZBTJ-ZSHC1614)`
Expect(body).To(Equal(expected))
})
})
It("should parse the header correctly", func() {
message := `
ZCZC TAF6789 160530
QU TSNZPCA
.
QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA
.TAF WSSS 160500Z 1606/1712 20010KT 9999 SCT018
BECMG 1608/1610 24012KT 9999 SCT018
TEMPO 1610/1612 4000 SHRA BKN012
BECMG 1612/1614 18008KT 9999 SCT020
BEGIN PART 02
(FORECAST AMENDMENT
VALID 1606/1700
THUNDERSTORMS EXPECTED
ALTERNATE ROUTES ADVISED)
NNNN`
parsedHeader, err := ParseHeader(message)
Expect(err).ToNot(HaveOccurred())
Expect(parsedHeader.MessageID).To(Equal("TAF6789"))
Expect(parsedHeader.DateTime).To(Equal("160530"))
Expect(parsedHeader.PriorityIndicator).To(Equal("QU"))
Expect(parsedHeader.PrimaryAddress).To(Equal("TSNZPCA"))
Expect(parsedHeader.SecondaryAddresses).To(Equal(" QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA"))
})
It("should parse the header correctly with originator information", func() {
message := `
ZCZC NOTAM1122 171000
QU TSNZPCA
.
QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA
.SELOZKE 170999
BEGIN PART 01
RUNWAY MAINTENANCE NOTICE.
- MAINTENANCE MANAGER: JOHN DOE
RUNWAY 09/27 WILL BE CLOSED FOR MAINTENANCE FROM 0800Z TO 1600Z.
- AIRPORT OPERATIONS: SIGN . . . . . . . . . .
WE ACKNOWLEDGE THE RUNWAY CLOSURE.
- CONTROL TOWER:
SIGN . . . . . . . . . .
BEGIN PART 02
(ALERT MESSAGE - WEATHER WARNING
VALID 1500Z - 1800Z
SEVERE THUNDERSTORM FORECASTED
ALL DEPARTURES/ARRIVALS EXPECTED TO BE DELAYED)
NNNN`
parsedHeader, err := ParseHeader(message)
Expect(err).ToNot(HaveOccurred())
Expect(parsedHeader.MessageID).To(Equal("NOTAM1122"))
Expect(parsedHeader.DateTime).To(Equal("171000"))
Expect(parsedHeader.PriorityIndicator).To(Equal("QU"))
Expect(parsedHeader.PrimaryAddress).To(Equal("TSNZPCA"))
Expect(parsedHeader.SecondaryAddresses).To(Equal(" QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA"))
Expect(parsedHeader.Originator).To(Equal("SELOZKE"))
Expect(parsedHeader.OriginatorDateTime).To(Equal("170999"))
})
})
Describe("Other Info", func() {
Context("PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED", func() {
It("should parse the other info correctly", func() {
otherInfo := "PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED"
parsed := parseOther(otherInfo)
Expect(parsed).ToNot(BeNil())
Expect(parsed[PBN]).To(Equal("A1B2B3B4B5D1L1"))
Expect(parsed[NavigationEquipment]).To(Equal("ABAS"))
Expect(parsed[Register]).To(Equal("B6513"))
Expect(parsed[EstimatedElapsedTime]).To(Equal("ZBPE0112"))
Expect(parsed[SELCALCode]).To(Equal("KMAL"))
Expect(parsed[PerformanceCategory]).To(Equal("C"))
Expect(parsed[RerouteInformation]).To(Equal("FRT N640 ZBYN"))
Expect(parsed[Remarks]).To(Equal("TCAS EQUIPPED"))
})
})
})
Describe("ParseBody", func() {
Context("with ARR body (ARR-CES5470-ZBTJ-ZSHC1614)", func() {
body := "(ARR-CES5470-ZBTJ-ZSHC1614)"
parser := NewBodyParser(body)
It("should parse the body correctly", func() {
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("ARR"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.ARR{}))
arrMessage := parsedBody.(*domain.ARR)
Expect(arrMessage.Category).To(Equal("ARR"))
Expect(arrMessage.AircraftID).To(Equal("CES5470"))
Expect(arrMessage.DepartureAirport).To(Equal("ZBTJ"))
Expect(arrMessage.ArrivalAirport).To(Equal("ZSHC"))
Expect(arrMessage.ArrivalTime).To(Equal("1614"))
})
})
Context("with ARR body", func() {
// parser := NewBodyParser(body)
It("should parse the body (ARR-AB123/A1234-KJFK-KLAX1234) correctly", func() {
body := " (ARR-AB123/A1234-KJFK-KLAX1234)"
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("ARR"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.ARR{}))
arrMessage := parsedBody.(*domain.ARR)
Expect(arrMessage.Category).To(Equal("ARR"))
Expect(arrMessage.AircraftID).To(Equal("AB123"))
Expect(arrMessage.SSRModeAndCode).To(Equal("A1234"))
Expect(arrMessage.DepartureAirport).To(Equal("KJFK"))
Expect(arrMessage.ArrivalAirport).To(Equal("KLAX"))
})
It("should parse the body (ARR-JAE7433/A0132-RKSI-ZBTJ1604) correctly", func() {
body := " (ARR-JAE7433/A0132-RKSI-ZBTJ1604)"
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("ARR"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.ARR{}))
arrMessage := parsedBody.(*domain.ARR)
Expect(arrMessage.Category).To(Equal("ARR"))
Expect(arrMessage.AircraftID).To(Equal("JAE7433"))
Expect(arrMessage.SSRModeAndCode).To(Equal("A0132"))
Expect(arrMessage.DepartureAirport).To(Equal("RKSI"))
Expect(arrMessage.ArrivalAirport).To(Equal("ZBTJ"))
})
})
Context("with DEP body", func() {
// parser := NewBodyParser()
It("should parse the body (DEP-CYZ9017/A5633-ZBTJ1638-ZSPD) correctly", func() {
body := "(DEP-CYZ9017/A5633-ZBTJ1638-ZSPD)"
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("DEP"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.DEP{}))
depMessage := parsedBody.(*domain.DEP)
Expect(depMessage.Category).To(Equal("DEP"))
Expect(depMessage.AircraftID).To(Equal("CYZ9017"))
Expect(depMessage.SSRModeAndCode).To(Equal("A5633"))
Expect(depMessage.DepartureAirport).To(Equal("ZBTJ"))
Expect(depMessage.DepartureTime).To(Equal("1638"))
Expect(depMessage.Destination).To(Equal("ZSPD"))
})
})
Context("with FPL body", func() {
// parser := NewBodyParser()
It("should parse the body correctly", func() {
body := `(FPL-CCA1532-IS
-A332/H
-SDE3FGHIJ4J5M1RWY/LB101
-ZSSS2035
-K0859S1040 PIAKS G330 PIMOL A539 BTO W82 DOGAR
-ZBAA0153 ZBYN
-PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED)`
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("FPL"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.FPL{}))
fplMessage := parsedBody.(*domain.FPL)
Expect(fplMessage.FlightNumber).To(Equal("CCA1532"))
Expect(fplMessage.FlightRulesAndType).To(Equal("IS"))
Expect(fplMessage.AircraftID).To(Equal("A332/H"))
Expect(fplMessage.SSRModeAndCode).To(Equal("SDE3FGHIJ4J5M1RWY/LB101"))
Expect(fplMessage.DepartureAirport).To(Equal("ZSSS"))
Expect(fplMessage.DepartureTime).To(Equal("2035"))
Expect(fplMessage.CruisingSpeedAndLevel).To(Equal("K0859S1040"))
Expect(fplMessage.Route).To(Equal("PIAKS G330 PIMOL A539 BTO W82 DOGAR"))
Expect(fplMessage.DestinationAndTotalTime).To(Equal("ZBAA0153"))
Expect(fplMessage.AlternateAirport).To(Equal("ZBYN"))
Expect(fplMessage.OtherInfo).To(Equal("PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED"))
Expect(fplMessage.PBN).To(Equal("A1B2B3B4B5D1L1"))
Expect(fplMessage.EstimatedElapsedTime).To(Equal("ZBPE0112"))
Expect(fplMessage.SELCALCode).To(Equal("KMAL"))
Expect(fplMessage.PerformanceCategory).To(Equal("C"))
Expect(fplMessage.RerouteInformation).To(Equal("FRT N640 ZBYN"))
Expect(fplMessage.Remarks).To(Equal("TCAS EQUIPPED"))
})
})
Context("with CNL body", func() {
// parser := NewBodyParser()
It("should parse the body correctly", func() {
body := "(CNL-YZR7979-ZSPD-ZBTJ)"
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("CNL"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.CNL{}))
cnlMessage := parsedBody.(*domain.CNL)
Expect(cnlMessage.Category).To(Equal("CNL"))
Expect(cnlMessage.AircraftID).To(Equal("YZR7979"))
})
})
Context("with DLA body", func() {
It("should parse the body correctly", func() {
body := "(DLA-CSN3133-ZGGG0110-ZBTJ)"
parser := NewBodyParser(body)
category, parsedBody, err := parser.Parse()
Expect(err).ToNot(HaveOccurred())
Expect(parsedBody).ToNot(BeNil())
Expect(category).To(Equal("DLA"))
Expect(parsedBody).To(BeAssignableToTypeOf(&domain.DLA{}))
dlaMessage := parsedBody.(*domain.DLA)
Expect(dlaMessage.AircraftID).To(Equal("CSN3133"))
Expect(dlaMessage.DepartureAirport).To(Equal("ZGGG"))
Expect(dlaMessage.NewDepartureTime).To(Equal("0110"))
Expect(dlaMessage.ArrivalAirport).To(Equal("ZBTJ"))
})
})
})
Describe("Parse whole real message", func() {
Context("with a real ARR message", func() {
message := `
ZCZC TMQ2526 141605
FF ZBTJZPZX
141604 ZBACZQZX
(ARR-JAE7433/A0132-RKSI-ZBTJ1604)
NNNN
`
It("should parse the whole message correctly", func() {
parsedMessage, err := Parse(message)
Expect(err).ToNot(HaveOccurred())
Expect(parsedMessage).ToNot(BeNil())
Expect(parsedMessage.Parsed).To(BeTrue())
Expect(parsedMessage.MessageID).To(Equal("TMQ2526"))
Expect(parsedMessage.DateTime).To(Equal("141605"))
Expect(parsedMessage.PrimaryAddress).To(Equal("ZBTJZPZX"))
Expect(parsedMessage.SecondaryAddresses).To(Equal(""))
Expect(parsedMessage.PriorityIndicator).To(Equal("FF"))
Expect(parsedMessage.OriginatorDateTime).To(Equal("141604"))
Expect(parsedMessage.Originator).To(Equal("ZBACZQZX"))
arrmsg := parsedMessage.BodyData.(*domain.ARR)
Expect(arrmsg.Category).To(Equal("ARR"))
Expect(arrmsg.AircraftID).To(Equal("JAE7433"))
Expect(arrmsg.SSRModeAndCode).To(Equal("A0132"))
Expect(arrmsg.DepartureAirport).To(Equal("RKSI"))
Expect(arrmsg.ArrivalAirport).To(Equal("ZBTJ"))
Expect(arrmsg.ArrivalTime).To(Equal("1604"))
})
})
Context("with this real FPL message", func() {
message := `ZCZC TMQ2617 142150
GG ZBTJZPZX
150551 ZBTJUOBK
(FPL-OKA2861-IS
-MA60/M-SHID/C
-ZBTJ0030
-K0420S0450 CG J1 FZ
-ZSYT0100 ZSQD ZYTL
-REG/B3710 SEL/ RMK/TCAS )
NNNN
`
It("should parse the whole message correctly", func() {
parsedMessage, err := Parse(message)
Expect(err).ToNot(HaveOccurred())
Expect(parsedMessage).ToNot(BeNil())
Expect(parsedMessage.Parsed).To(BeTrue())
Expect(parsedMessage.MessageID).To(Equal("TMQ2617"))
Expect(parsedMessage.DateTime).To(Equal("142150"))
Expect(parsedMessage.PrimaryAddress).To(Equal("ZBTJZPZX"))
Expect(parsedMessage.SecondaryAddresses).To(Equal(""))
Expect(parsedMessage.PriorityIndicator).To(Equal("GG"))
Expect(parsedMessage.OriginatorDateTime).To(Equal("150551"))
Expect(parsedMessage.Originator).To(Equal("ZBTJUOBK"))
fplmsg := parsedMessage.BodyData.(*domain.FPL)
Expect(fplmsg.Category).To(Equal("FPL"))
Expect(fplmsg.FlightNumber).To(Equal("OKA2861"))
Expect(fplmsg.FlightRulesAndType).To(Equal("IS"))
Expect(fplmsg.AircraftID).To(Equal("MA60/M"))
Expect(fplmsg.SSRModeAndCode).To(Equal("SHID/C"))
Expect(fplmsg.DepartureAirport).To(Equal("ZBTJ"))
Expect(fplmsg.DepartureTime).To(Equal("0030"))
Expect(fplmsg.CruisingSpeedAndLevel).To(Equal("K0420S0450"))
Expect(fplmsg.Route).To(Equal("CG J1 FZ"))
Expect(fplmsg.DestinationAndTotalTime).To(Equal("ZSYT0100"))
Expect(fplmsg.AlternateAirport).To(Equal("ZSQD ZYTL"))
Expect(fplmsg.OtherInfo).To(Equal("REG/B3710 SEL/ RMK/TCAS"))
// Expect(fplmsg.PBN).To(Equal("B3710"))
Expect(fplmsg.SELCALCode).To(Equal(""))
Expect(fplmsg.Remarks).To(Equal("TCAS"))
})
})
})
Describe("Utility Functions", func() {
It("should clean text correctly", func() {
text := `ZCZC TMQ2530 141614
1234
4567
NNNN`
expect := "ZCZC TMQ2530 141614\n1234\n 4567"
cleaned := cleanMessage(text)
Expect(cleaned).To(Equal(expect))
})
It("should parse start indicator correctly", func() {
line := "ZCZC TMQ2530 141614"
startIndicator, messageID, dateTime, err := parseStartIndicator(line)
Expect(err).ToNot(HaveOccurred())
Expect(startIndicator).To(Equal("ZCZC"))
Expect(messageID).To(Equal("TMQ2530"))
Expect(dateTime).To(Equal("141614"))
})
It("should return error for invalid start indicator line", func() {
line := "Invalid Line"
_, _, _, err := parseStartIndicator(line)
Expect(err).To(HaveOccurred())
})
It("should parse priority and primary address correctly", func() {
line := "QU TSNZPCA"
priority, primary := parsePriorityAndPrimary(line)
Expect(priority).To(Equal("QU"))
Expect(primary).To(Equal("TSNZPCA"))
})
It("should return empty strings for invalid priority and primary address line", func() {
line := "Invalid-Line"
priority, primary := parsePriorityAndPrimary(line)
Expect(priority).To(BeEmpty())
Expect(primary).To(BeEmpty())
})
It("should parse remaining lines correctly", func() {
lines := []string{"QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA", ".SELOZKE 170999", "BEGIN PART 01"}
secondaryAddresses, originator, originatorDateTime, bodyAndFooter := parseRemainingLines(lines)
Expect(secondaryAddresses).To(Equal(" QU PEKUDCA TSNUOCA TSNZPCA TSNUFCA"))
Expect(originator).To(Equal("SELOZKE"))
Expect(originatorDateTime).To(Equal("170999"))
Expect(bodyAndFooter).To(Equal("BEGIN PART 01\n"))
})
})
})
@@ -0,0 +1,79 @@
package aviation
import "regexp"
// String constants
const (
StartIndicatorPrefix = "ZCZC"
EndHeaderMarker = "."
BeginPartMarker = "BEGIN PART"
Category = "category"
CategoryArrival = "ARR"
CategoryDeparture = "DEP"
CategoryCancellation = "CNL"
CategoryDelay = "DLA"
CategoryFlightPlan = "FPL"
FlightNumber = "number"
Register = "reg"
SSR = "ssr"
DepartureCode = "dep"
DepartureTime = "dep_time"
ArrivalCode = "arr"
ArrivalTime = "arr_time"
DestinationCode = "dest"
OtherInfo = "other"
ReferenceData = "reference_data"
CategorySurveillance = "surve"
Indicator = "indicator"
Other = "other"
AircraftID = "aircraft"
Surveillance = "surve"
Speed = "speed"
Level = "level"
Route = "route"
EstimatedTime = "estt"
AlternateAirport = "alter"
PBN = "pbn"
NavigationEquipment = "nav"
EstimatedElapsedTime = "eet"
SELCALCode = "sel"
PerformanceCategory = "per"
RerouteInformation = "rif"
Remarks = "remark"
)
// Regular expression patterns
const (
ArrPatternString = `^\((?P<category>[A-Z]{3})-(?P<number>[A-Z0-9]+)(\/?(?P<ssr>[A-Z0-9]+))?-(?P<dep>[A-Z]{4})-(?P<arr>[A-Z]{4})(?P<arr_time>\d{4})\)$`
DepPatternString = `^\((?P<category>[A-Z]{3})-(?P<number>[A-Z0-9]+)(\/(?P<ssr>[A-Z0-9]+))?-(?P<dep>[A-Z]{4})(?P<dep_time>\d{4})-(?P<arr>[A-Z]{4})\)$`
FplPatternString = `\((?P<category>[A-Z]{3})-(?P<number>[A-Z]+\d+)-(?P<indicator>[A-Z]{2})\n-(?P<aircraft>[A-Z]+\d+\/?[A-Z]?)\n?-(?P<surve>.*)\n?-(?P<dep>[A-Z]{4})(?P<dep_time>\d{4})\n?-(?P<speed>[A-Z]+\d+)(?P<level>[A-Z0-9]+)\s+(?P<route>(.|\n)+)\n-(?P<dest>[A-Z]{4})(?P<estt>\d{4})\s?(?P<alter>(\s[A-Z]{4})+)\n?-([A-Z]{3}\/(?:[A-Z]{4}\d{4}\s?)+)?(?P<other>(?m)[A-Z]{3}\/(.|\n)*)\)$`
CnlPatternString = `^\((?P<category>[A-Z]{3})-(?P<number>\w+\d+)-?(?P<dep>[A-Z]{4})?-?(?<arr>[A-Z]{4})\)$`
DlaPatternString = `^\((?P<category>[A-Z]{3})-(?P<number>\w+\d+)-?(?P<dep>[A-Z]{4})(?P<dep_time>\d{4})?-?(?<arr>[A-Z]{4})(?<arr_time>\d{4})?\)$`
)
// Compiled regular expressions
var (
ArrPatternExpression = regexp.MustCompile(ArrPatternString)
DepPatternExpression = regexp.MustCompile(DepPatternString)
FplPatternExpression = regexp.MustCompile(FplPatternString)
CnlPatternExpression = regexp.MustCompile(CnlPatternString)
DlaPatternExpression = regexp.MustCompile(DlaPatternString)
BodyTypePattern = regexp.MustCompile(`^\(([A-Z]{3})(.*\n?)+\)$`)
categoryRegex = regexp.MustCompile(`\((?P<category>[A-Z]+)-`)
emptyLineRemove = regexp.MustCompile(`(?m)^\s*$`)
bodyOnly = regexp.MustCompile(`(.|\n)?(ZCZC(.|\n)*)NNNN(.|\n)?$`)
originator = regexp.MustCompile(`(?P<originatorDateTime>[0-9]+)\s(?P<originator>[A-Z]+)`)
navPattern = regexp.MustCompile(`(?m)NAV\/(?P<nav>\w+)`)
remarkPattern = regexp.MustCompile(`(?s)RMK\/(?P<remark>.*)`)
selPattern = regexp.MustCompile(`(?m)SEL\/(?P<sel>\w+)`)
regPattern = regexp.MustCompile(`(?m)REG\/(?P<reg>[A-Z0-9]+)`)
pbnPattern = regexp.MustCompile(`(?m)PBN\/(?P<pbn>[A-Z0-9]+)`)
eetPattern = regexp.MustCompile(`(?s)(-?EET\/(?P<eet>(?:[A-Z]{4}\d{4}\s*)+))`)
performancePattern = regexp.MustCompile(`(?s)-?PER\/(?P<per>\w)`)
reroutePattern = regexp.MustCompile(`(?m)RIF\/(?P<rif>.*)[A-Z]{3}\/`)
)
@@ -0,0 +1,99 @@
package aviation
import "regexp"
// BodyConfig represents the configuration for parsing message bodies.
type BodyConfig struct {
Patterns []PatternConfig
}
// PatternConfig represents the configuration for a specific pattern.
type PatternConfig struct {
Pattern string
Comments string
Expression *regexp.Regexp
}
var (
bodyPatterns = map[string]BodyConfig{}
)
func init() {
// Initialize body patterns.
bodyPatterns = map[string]BodyConfig{
"ARR": {
Patterns: []PatternConfig{
{
Pattern: ArrPatternString,
Comments: "Pattern for ARR message",
Expression: ArrPatternExpression,
},
},
},
"DEP": {
Patterns: []PatternConfig{
{
Pattern: DepPatternString,
Comments: "Pattern for DEP message",
Expression: DepPatternExpression,
},
},
},
"FPL": {
Patterns: []PatternConfig{
{
Pattern: FplPatternString,
Comments: "Pattern for FPL message",
Expression: FplPatternExpression,
},
},
},
"CNL": {
Patterns: []PatternConfig{
{
Pattern: CnlPatternString,
Comments: "Pattern for CNL message",
Expression: CnlPatternExpression,
},
},
},
"DLA": {
Patterns: []PatternConfig{
{
Pattern: DlaPatternString,
Comments: "Pattern for DLA message",
Expression: DlaPatternExpression,
},
},
},
}
}
// FindPatterns finds the matching body configuration based on the message body.
func FindPatterns(messageBody string) *BodyConfig {
if match := BodyTypePattern.FindStringSubmatch(messageBody); len(match) > 1 {
name := match[1]
if bodyConfig, found := bodyPatterns[name]; found {
return &bodyConfig
}
}
return nil
}
// ParseBody parses the message body and returns the extracted values.
func ParseBody(messageBody string) map[string]string {
if body := FindPatterns(messageBody); body != nil {
for _, pattern := range body.Patterns {
if matches := pattern.Expression.FindStringSubmatch(messageBody); matches != nil {
result := make(map[string]string)
for i, name := range pattern.Expression.SubexpNames() {
if i != 0 && name != "" {
result[name] = matches[i]
}
}
return result
}
}
}
return nil
}
@@ -0,0 +1,43 @@
package aviation
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Pattern Parser", func() {
Describe("FindPatterns", func() {
It("should return the correct BodyConfig based on the message body", func() {
message := "(ARR-AB123-SSR1234-KJFK-KLAX)"
bodyConfig := FindPatterns(message)
Expect(bodyConfig).NotTo(BeNil())
// Expect(bodyConfig.Name).To(Equal("ARR"))
})
It("should return nil if no pattern matches", func() {
message := "(XYZ-123)"
bodyConfig := FindPatterns(message)
Expect(bodyConfig).To(BeNil())
})
})
Describe("ParseBody", func() {
It("should parse the message body and extract data based on patterns", func() {
message := "(ARR-AB123/A1234-KJFK-KLAX1234)"
parsedData := ParseBody(message)
Expect(parsedData).NotTo(BeNil())
Expect(parsedData["category"]).To(Equal("ARR"))
Expect(parsedData["number"]).To(Equal("AB123"))
Expect(parsedData["ssr"]).To(Equal("A1234"))
Expect(parsedData[DepartureCode]).To(Equal("KJFK"))
Expect(parsedData[ArrivalCode]).To(Equal("KLAX"))
})
It("should return nil if no patterns match", func() {
message := "(XYZ-123)"
parsedData := ParseBody(message)
Expect(parsedData).To(BeNil())
})
})
})
@@ -0,0 +1,16 @@
package aviation
import "caatsm/internal/adapter/dto"
// AviationParser implements the Parser interface for aviation telegrams.
type AviationParser struct{}
// NewParser creates a new aviation parser instance.
func NewParser() *AviationParser {
return &AviationParser{}
}
// Parse parses a raw message string and returns a ParsedTelegram.
func (p *AviationParser) Parse(rawText string) (*dto.ParsedTelegram, error) {
return Parse(rawText)
}