refactor aviation parser architecture

This commit is contained in:
windyboy
2025-12-24 17:40:52 +08:00
parent b82b707a25
commit ba82b9206a
5 changed files with 395 additions and 124 deletions
+17 -72
View File
@@ -2,7 +2,6 @@ package aviation
import (
"caatsm/internal/adapter/dto"
"caatsm/internal/domain"
"errors"
"fmt"
"regexp"
@@ -68,13 +67,26 @@ func (parser *BodyParser) Parse() (string, interface{}, error) {
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)
patternConfig, exists := parser.bodyPatterns[category]
if !exists || patternConfig.Patterns == nil {
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
}
ctx := ParseContext{
Body: parser.body,
Tokens: Tokenizer{}.Tokenize(parser.body),
}
for _, p := range patternConfig.Patterns {
if data := extract(parser.body, p.Expression); data != nil {
parsed, err := parseCategory(category, ctx, data)
if err != nil {
return "", nil, err
}
return category, parsed, nil
}
}
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
}
@@ -107,73 +119,6 @@ func extractData(match []string, re *regexp.Regexp) map[string]string {
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,
+1 -52
View File
@@ -15,60 +15,9 @@ type PatternConfig struct {
}
var (
bodyPatterns = map[string]BodyConfig{}
bodyPatterns = buildBodyPatterns()
)
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 {
@@ -0,0 +1,188 @@
package aviation
import (
"caatsm/internal/domain"
"fmt"
)
// ParseContext carries the raw body and tokens for field parsers.
type ParseContext struct {
Body string
Tokens []Token
}
// CategoryParser maps a category to patterns and output parsing.
type CategoryParser interface {
Category() string
Patterns() []PatternConfig
Parse(ctx ParseContext, data map[string]string) (interface{}, error)
}
type arrParser struct{}
func (arrParser) Category() string { return CategoryArrival }
func (arrParser) Patterns() []PatternConfig {
return []PatternConfig{
{
Pattern: ArrPatternString,
Comments: "Pattern for ARR message",
Expression: ArrPatternExpression,
},
}
}
func (arrParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) {
return &domain.ARR{
Category: data[Category],
AircraftID: data[FlightNumber],
SSRModeAndCode: data[SSR],
DepartureAirport: data[DepartureCode],
ArrivalAirport: data[ArrivalCode],
ArrivalTime: data[ArrivalTime],
}, nil
}
type depParser struct{}
func (depParser) Category() string { return CategoryDeparture }
func (depParser) Patterns() []PatternConfig {
return []PatternConfig{
{
Pattern: DepPatternString,
Comments: "Pattern for DEP message",
Expression: DepPatternExpression,
},
}
}
func (depParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) {
return &domain.DEP{
Category: data[Category],
AircraftID: data[FlightNumber],
SSRModeAndCode: data[SSR],
DepartureAirport: data[DepartureCode],
DepartureTime: data[DepartureTime],
Destination: data[ArrivalCode],
}, nil
}
type cnlParser struct{}
func (cnlParser) Category() string { return CategoryCancellation }
func (cnlParser) Patterns() []PatternConfig {
return []PatternConfig{
{
Pattern: CnlPatternString,
Comments: "Pattern for CNL message",
Expression: CnlPatternExpression,
},
}
}
func (cnlParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) {
return &domain.CNL{
Category: data[Category],
AircraftID: data[FlightNumber],
DepartureAirport: data[DepartureCode],
DestinationAirport: data[ArrivalCode],
}, nil
}
type dlaParser struct{}
func (dlaParser) Category() string { return CategoryDelay }
func (dlaParser) Patterns() []PatternConfig {
return []PatternConfig{
{
Pattern: DlaPatternString,
Comments: "Pattern for DLA message",
Expression: DlaPatternExpression,
},
}
}
func (dlaParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) {
return &domain.DLA{
Category: data[Category],
AircraftID: data[FlightNumber],
DepartureAirport: data[DepartureCode],
NewDepartureTime: data[DepartureTime],
ArrivalAirport: data[ArrivalCode],
ArrivalTime: data[ArrivalTime],
}, nil
}
type fplParser struct{}
func (fplParser) Category() string { return CategoryFlightPlan }
func (fplParser) Patterns() []PatternConfig {
return []PatternConfig{
{
Pattern: FplPatternString,
Comments: "Pattern for FPL message",
Expression: FplPatternExpression,
},
}
}
func (fplParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) {
otherData := parseOther(data[OtherInfo])
return &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
}
var categoryRegistry = map[string]CategoryParser{
CategoryArrival: arrParser{},
CategoryDeparture: depParser{},
CategoryCancellation: cnlParser{},
CategoryDelay: dlaParser{},
CategoryFlightPlan: fplParser{},
}
func lookupCategoryParser(category string) (CategoryParser, bool) {
parser, ok := categoryRegistry[category]
return parser, ok
}
func buildBodyPatterns() map[string]BodyConfig {
patterns := make(map[string]BodyConfig, len(categoryRegistry))
for category, parser := range categoryRegistry {
patterns[category] = BodyConfig{Patterns: parser.Patterns()}
}
return patterns
}
func parseCategory(category string, ctx ParseContext, data map[string]string) (interface{}, error) {
parser, ok := lookupCategoryParser(category)
if !ok {
return nil, fmt.Errorf("invalid message type: %s", category)
}
return parser.Parse(ctx, data)
}
@@ -0,0 +1,61 @@
package aviation
import "strings"
// Token represents a lexeme in the body with its byte offsets.
type Token struct {
Text string
Start int
End int
}
// Tokenizer splits text into tokens using a whitespace set.
// A forward slash is treated as whitespace but is emitted as its own token.
type Tokenizer struct {
Whitespace string
}
// Tokenize tokenizes input and returns tokens with byte offsets.
func (t Tokenizer) Tokenize(input string) []Token {
if t.Whitespace == "" {
t.Whitespace = " \n\t\r"
}
var tokens []Token
start := -1
for idx, r := range input {
if strings.ContainsRune(t.Whitespace, r) {
if start != -1 {
tokens = append(tokens, Token{
Text: input[start:idx],
Start: start,
End: idx,
})
start = -1
}
if r == '/' {
tokens = append(tokens, Token{
Text: "/",
Start: idx,
End: idx + 1,
})
}
continue
}
if start == -1 {
start = idx
}
}
if start != -1 {
tokens = append(tokens, Token{
Text: input[start:],
Start: start,
End: len(input),
})
}
return tokens
}
@@ -0,0 +1,128 @@
package aviation
import (
"caatsm/internal/domain"
"testing"
)
func TestTokenizerDefaultWhitespace(t *testing.T) {
t.Parallel()
input := "A B\nC\tD\rE"
tokens := Tokenizer{}.Tokenize(input)
expected := []Token{
{Text: "A", Start: 0, End: 1},
{Text: "B", Start: 2, End: 3},
{Text: "C", Start: 4, End: 5},
{Text: "D", Start: 6, End: 7},
{Text: "E", Start: 8, End: 9},
}
if len(tokens) != len(expected) {
t.Fatalf("expected %d tokens, got %d", len(expected), len(tokens))
}
for i, token := range tokens {
if token != expected[i] {
t.Fatalf("token %d mismatch: got %#v, expected %#v", i, token, expected[i])
}
}
}
func TestTokenizerSlashWhitespace(t *testing.T) {
t.Parallel()
input := "A/B C"
tokens := Tokenizer{Whitespace: " \n\t\r/"}.Tokenize(input)
expected := []Token{
{Text: "A", Start: 0, End: 1},
{Text: "/", Start: 1, End: 2},
{Text: "B", Start: 2, End: 3},
{Text: "C", Start: 4, End: 5},
}
if len(tokens) != len(expected) {
t.Fatalf("expected %d tokens, got %d", len(expected), len(tokens))
}
for i, token := range tokens {
if token != expected[i] {
t.Fatalf("token %d mismatch: got %#v, expected %#v", i, token, expected[i])
}
}
}
func TestBuildBodyPatternsIncludesCategories(t *testing.T) {
t.Parallel()
patterns := buildBodyPatterns()
for _, category := range []string{
CategoryArrival,
CategoryDeparture,
CategoryCancellation,
CategoryDelay,
CategoryFlightPlan,
} {
config, ok := patterns[category]
if !ok {
t.Fatalf("expected category %s in body patterns", category)
}
if len(config.Patterns) == 0 || config.Patterns[0].Expression == nil {
t.Fatalf("expected pattern expression for category %s", category)
}
}
}
func TestParseCategoryInvalid(t *testing.T) {
t.Parallel()
_, err := parseCategory("XYZ", ParseContext{}, map[string]string{})
if err == nil {
t.Fatal("expected error for invalid category")
}
}
func TestParseCategoryFPL(t *testing.T) {
t.Parallel()
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)`
data := extract(body, FplPatternExpression)
if data == nil {
t.Fatal("expected FPL pattern to match")
}
parsed, err := parseCategory(CategoryFlightPlan, ParseContext{
Body: body,
Tokens: Tokenizer{}.Tokenize(body),
}, data)
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
fpl, ok := parsed.(*domain.FPL)
if !ok {
t.Fatalf("expected *domain.FPL, got %T", parsed)
}
if fpl.FlightNumber != "CCA1532" {
t.Fatalf("expected flight number CCA1532, got %s", fpl.FlightNumber)
}
if fpl.PBN != "A1B2B3B4B5D1L1" {
t.Fatalf("expected PBN A1B2B3B4B5D1L1, got %s", fpl.PBN)
}
if fpl.RerouteInformation != "FRT N640 ZBYN" {
t.Fatalf("expected reroute information, got %s", fpl.RerouteInformation)
}
if fpl.Remarks != "TCAS EQUIPPED" {
t.Fatalf("expected remarks TCAS EQUIPPED, got %s", fpl.Remarks)
}
}