diff --git a/accessor_test.go b/accessor_test.go index ee3a5e5..8167346 100644 --- a/accessor_test.go +++ b/accessor_test.go @@ -57,6 +57,11 @@ func TestLocationString(t *testing.T) { loc: Location{Segment: "NK", Field: 1}, want: "NK-1", }, + { + name: "component zero omitted", + loc: Location{Segment: "PID", Field: 3, Component: 0}, + want: "PID-3", + }, } for _, tt := range tests { @@ -318,26 +323,40 @@ func TestParseLocationEdgeCases(t *testing.T) { name string input string wantErr bool + wantLoc Location // asserted when non-zero (Segment != "") }{ - {"EmptySegmentIndex", "OBX()-5", true}, - {"SingleCharSegment", "X-1", true}, - {"BadRepIndex", "PID-3[abc]", true}, - {"BadComponentNum", "PID-3.abc", true}, - {"BadSubcomponentNum", "PID-3.1.abc", true}, - {"EmptyBrackets", "PID-3[]", true}, - {"EmptyFieldPart", "PID-", true}, - {"TrailingGarbageAfterRep", "PID-3[1]x", true}, - {"TrailingGarbageAfterSegIndex", "OBX(1)x-5", true}, + {"EmptySegmentIndex", "OBX()-5", true, Location{}}, + {"SingleCharSegment", "X-1", true, Location{}}, + {"BadRepIndex", "PID-3[abc]", true, Location{}}, + {"BadComponentNum", "PID-3.abc", true, Location{}}, + {"BadSubcomponentNum", "PID-3.1.abc", true, Location{}}, + {"EmptyBrackets", "PID-3[]", true, Location{}}, + {"EmptyFieldPart", "PID-", true, Location{}}, + {"TrailingGarbageAfterRep", "PID-3[1]x", true, Location{}}, + {"TrailingGarbageAfterSegIndex", "OBX(1)x-5", true, Location{}}, // ZeroComponent documents that ".0" is accepted and treated as // "component not specified" (equivalent to omitting the component). - {"ZeroComponent", "PID-3.0", false}, + {"ZeroComponent", "PID-3.0", false, Location{Segment: "PID", Field: 3, Component: 0}}, + // Trailing dot after field: accepted, Component stays 0. + {"TrailingDotAfterField", "PID-3.", false, Location{Segment: "PID", Field: 3, Component: 0}}, + // Trailing dot after repetition: accepted, Component stays 0. + {"TrailingDotAfterRepetition", "PID-3[1].", false, Location{Segment: "PID", Field: 3, Repetition: 1, Component: 0}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := ParseLocation(tt.input) - if tt.wantErr && err == nil { - t.Errorf("expected error for %q", tt.input) + got, err := ParseLocation(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q, got %+v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.input, err) + } + if tt.wantLoc.Segment != "" && got != tt.wantLoc { + t.Errorf("ParseLocation(%q) = %+v, want %+v", tt.input, got, tt.wantLoc) } }) } @@ -378,6 +397,33 @@ func TestGetSubcomponentEmpty(t *testing.T) { } } +func TestMessageGetPresentButEmptyField(t *testing.T) { + // PID-2 is between two consecutive field separators (||) — present but empty. + raw := []byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + msg, err := ParseMessage(raw) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + + v := msg.Get("PID-2") + if !v.IsEmpty() { + t.Errorf("Get(PID-2).IsEmpty() = false, want true") + } + if v.String() != "" { + t.Errorf("Get(PID-2).String() = %q, want empty", v.String()) + } + // A present-but-empty field returns a zero Value (nil raw): the early-return + // in getByLocation when field.IsEmpty() is true yields a zero Value. + if v.Raw() != nil { + t.Errorf("Get(PID-2).Raw() = %v, want nil", v.Raw()) + } + + // Contrast: PID-3 has a value. + if got := msg.Get("PID-3").String(); got != "123" { + t.Errorf("Get(PID-3).String() = %q, want 123", got) + } +} + func BenchmarkParseLocation(b *testing.B) { inputs := []struct { name string diff --git a/ack_test.go b/ack_test.go index a80345f..7e407a9 100644 --- a/ack_test.go +++ b/ack_test.go @@ -624,6 +624,66 @@ func TestAckWithErrors(t *testing.T) { t.Errorf("ERR-2.5 = %q, want 2", got) } }) + + t.Run("EmptyLocation", func(t *testing.T) { + // appendERL("", d) hits the len < 2 guard and returns buf unchanged. + // ERR-2 should be empty while ERR-5 (code) and ERR-7 (description) are set. + msg := parseTestMsg(t, baseMSH) + issues := []Issue{ + {Severity: SeverityError, Location: "", Code: "CUSTOM", Description: "desc"}, + } + + ack, err := msg.Ack(AE, "ACK-X", WithTimestamp(fixedTime), WithErrors(issues)) + if err != nil { + t.Fatalf("Ack: %v", err) + } + + ackMsg, err := ParseMessage(ack) + if err != nil { + t.Fatalf("ParseMessage(ack): %v", err) + } + + errSeg := &ackMsg.Segments()[2] + // ERR-2.1 should be empty (no ERL constructed for empty location). + if got := errSeg.Field(2).Rep(0).Component(1).String(); got != "" { + t.Errorf("ERR-2.1 = %q, want empty", got) + } + // ERR-5.1 should carry the error code. + if got := errSeg.Field(5).Rep(0).Component(1).String(); got != "CUSTOM" { + t.Errorf("ERR-5.1 = %q, want CUSTOM", got) + } + // ERR-7 should carry the description. + if got := errSeg.Field(7).String(); got != "desc" { + t.Errorf("ERR-7 = %q, want desc", got) + } + }) +} + +func TestAckTruncatedMSH(t *testing.T) { + fixedTime := time.Date(2024, 1, 15, 12, 30, 0, 0, time.UTC) + // MSH with only 9 fields — MSH-10 through MSH-12 are absent. + // Missing fields return zero Values; Ack() must not panic or error. + msg := parseTestMsg(t, + "MSH|^~\\&|S|F|R|RF|20240115||ADT^A01") + + ack, err := msg.Ack(AA, "REPLY001", WithTimestamp(fixedTime)) + if err != nil { + t.Fatalf("Ack: %v", err) + } + + ackMsg, err := ParseMessage(ack) + if err != nil { + t.Fatalf("ParseMessage(ack): %v", err) + } + + // MSA-2 holds the original message control ID (MSH-10), which was absent. + if got := ackMsg.Get("MSA-2").String(); got != "" { + t.Errorf("MSA-2 = %q, want empty (original MSH-10 absent)", got) + } + // The supplied controlID must appear in the ACK MSH-10. + if got := ackMsg.Get("MSH-10").String(); got != "REPLY001" { + t.Errorf("MSH-10 = %q, want REPLY001", got) + } } func TestAckNoMSH(t *testing.T) { diff --git a/batch_test.go b/batch_test.go index 278069d..198907a 100644 --- a/batch_test.go +++ b/batch_test.go @@ -719,6 +719,111 @@ func TestBatchBuilderBuildIsReusable(t *testing.T) { } } +func TestBatchBuilderSetHeaderReservedFieldNums(t *testing.T) { + // SetHeader with keys 0, 1, 2 are silently ignored by Build() (loop starts at i=3). + bb := NewBatchBuilder() + bb.SetHeader(0, "ZERO") + bb.SetHeader(1, "SEP") + bb.SetHeader(2, "ENC") + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + // Field(1) must still be the literal field separator. + if got := batch.Header.Field(1).String(); got != "|" { + t.Errorf("BHS-1 = %q, want |", got) + } + // Field(2) must still be the encoding chars. + if got := batch.Header.Field(2).String(); got != "^~\\&" { + t.Errorf("BHS-2 = %q, want ^~\\&", got) + } + // Field(3) must be empty — "ZERO" should not have leaked in. + if got := batch.Header.Field(3); !got.IsEmpty() { + t.Errorf("BHS-3 = %q, want empty (reserved field 0 must not bleed through)", got.String()) + } +} + +func TestBatchBuilderMessageWithoutTrailingCR(t *testing.T) { + // ParseMessage never appends a trailing \r. Build() detects this and adds one. + raw := []byte("MSH|^~\\&|APP|FAC|||20240101||ADT^A01|001|P|2.5.1") + msg, err := ParseMessage(raw) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + // Confirm the precondition: no trailing \r. + msgRaw := msg.Raw() + if len(msgRaw) > 0 && msgRaw[len(msgRaw)-1] == '\r' { + t.Skip("message unexpectedly ends with \\r; precondition not met") + } + + bb := NewBatchBuilder() + bb.Add(msg) + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch round-trip: %v", err) + } + if len(batch.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(batch.Messages)) + } + if got := batch.Messages[0].Get("MSH-10").String(); got != "001" { + t.Errorf("MSH-10 = %q, want 001", got) + } +} + +func TestBatchBuilderSetHeaderEmptyValue(t *testing.T) { + // Empty string stored and passed through Escape fast-path; BHS-3 should be empty. + bb := NewBatchBuilder() + bb.SetHeader(3, "") + data, err := bb.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + if got := batch.Header.Field(3); !got.IsEmpty() { + t.Errorf("BHS-3 = %q, want empty", got.String()) + } +} + +func TestBatchBuilderBHS7RefreshAfterReset(t *testing.T) { + // After Reset(), Build() calls time.Now() again, so BHS-7 is never stale. + bb := NewBatchBuilder() + before := time.Now().Format("20060102150405") + bb.Reset() + data, err := bb.Build() + after := time.Now().Format("20060102150405") + if err != nil { + t.Fatalf("Build: %v", err) + } + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Fatal("expected BHS header") + } + got := batch.Header.Field(7).String() + if got < before || got > after { + t.Errorf("BHS-7 = %q after Reset, want between %q and %q", got, before, after) + } +} + func BenchmarkBatchBuilder1Message(b *testing.B) { msg, _ := ParseMessage([]byte("MSH|^~\\&|APP|FAC|REC|RFAC|20240101||ADT^A01|001|P|2.5.1\rPID|1||123^^^MRN||Smith^John|||M")) bb := NewBatchBuilder() diff --git a/escape_test.go b/escape_test.go index 0414560..dcf248c 100644 --- a/escape_test.go +++ b/escape_test.go @@ -297,4 +297,12 @@ func TestUnescapeEdgeCases(t *testing.T) { t.Errorf("got %q, want %q", got, `\X\`) } }) + + t.Run("HexThreeNibbles", func(t *testing.T) { + // \X41F\ — three hex chars: pair "41" decodes to 'A'; trailing 'F' is dropped. + got := Unescape([]byte(`\X41F\`), d) + if string(got) != "A" { + t.Errorf("got %q, want %q", got, "A") + } + }) } diff --git a/message_test.go b/message_test.go index 57c5455..a8b11c6 100644 --- a/message_test.go +++ b/message_test.go @@ -66,6 +66,26 @@ func TestParseMessage(t *testing.T) { } }) + t.Run("exactly minMSHLength", func(t *testing.T) { + // "MSH|^~\&" is exactly 8 bytes (minMSHLength) — the smallest valid message. + msg, err := ParseMessage([]byte("MSH|^~\\&")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msg.Segments()) != 1 { + t.Errorf("len(Segments()) = %d, want 1", len(msg.Segments())) + } + if got := msg.Segments()[0].Field(1).String(); got != "|" { + t.Errorf("Field(1) = %q, want |", got) + } + if got := msg.Segments()[0].Field(2).String(); got != "^~\\&" { + t.Errorf("Field(2) = %q, want ^~\\&", got) + } + if got := msg.Segments()[0].Field(3); !got.IsEmpty() { + t.Errorf("Field(3) should be empty, got %q", got.String()) + } + }) + t.Run("too short", func(t *testing.T) { _, err := ParseMessage([]byte("MSH")) if err != ErrMessageTooShort { diff --git a/segment_test.go b/segment_test.go index de9d316..88b7ae0 100644 --- a/segment_test.go +++ b/segment_test.go @@ -265,6 +265,110 @@ func TestSegmentNormalFieldEdgeCases(t *testing.T) { }) } +func TestIsHeaderSeg(t *testing.T) { + tests := []struct { + raw string + want bool + }{ + {"MSH|^~\\&", true}, + {"BHS|^~\\&", true}, + {"FHS|^~\\&", true}, + {"PID|1", false}, + {"ZZZ|data", false}, + {"msh|", false}, // lowercase: not a header seg + {"BH", false}, // too short + {"", false}, + } + for _, tt := range tests { + got := isHeaderSeg([]byte(tt.raw)) + if got != tt.want { + t.Errorf("isHeaderSeg(%q) = %v, want %v", tt.raw, got, tt.want) + } + } +} + +func TestSegmentBHSFieldAccess(t *testing.T) { + d := DefaultDelimiters() + + t.Run("BHS full", func(t *testing.T) { + s := Segment{raw: []byte("BHS|^~\\&|SEND|FAC|REC|RFAC|20240101"), delims: d} + + if got := s.Field(0).String(); got != "BHS" { + t.Errorf("Field(0) = %q, want BHS", got) + } + if got := s.Field(1).String(); got != "|" { + t.Errorf("Field(1) = %q, want |", got) + } + if got := s.Field(2).String(); got != "^~\\&" { + t.Errorf("Field(2) = %q, want ^~\\&", got) + } + if got := s.Field(3).String(); got != "SEND" { + t.Errorf("Field(3) = %q, want SEND", got) + } + if got := s.Field(4).String(); got != "FAC" { + t.Errorf("Field(4) = %q, want FAC", got) + } + if got := s.Field(100); !got.IsEmpty() { + t.Errorf("Field(100) should be empty, got %q", got.String()) + } + }) + + t.Run("FHS full", func(t *testing.T) { + s := Segment{raw: []byte("FHS|^~\\&|SEND|FAC|REC|RFAC|20240101"), delims: d} + + if got := s.Field(0).String(); got != "FHS" { + t.Errorf("Field(0) = %q, want FHS", got) + } + if got := s.Field(1).String(); got != "|" { + t.Errorf("Field(1) = %q, want |", got) + } + if got := s.Field(2).String(); got != "^~\\&" { + t.Errorf("Field(2) = %q, want ^~\\&", got) + } + if got := s.Field(3).String(); got != "SEND" { + t.Errorf("Field(3) = %q, want SEND", got) + } + }) + + t.Run("BHS only separator, no encoding chars", func(t *testing.T) { + s := Segment{raw: []byte("BHS|"), delims: d} + if got := s.Field(1).String(); got != "|" { + t.Errorf("Field(1) = %q, want |", got) + } + if got := s.Field(2); !got.IsEmpty() { + t.Errorf("Field(2) should be empty, got %q", got.String()) + } + }) +} + +func TestSegmentBHSFieldCount(t *testing.T) { + d := DefaultDelimiters() + + t.Run("BHS with fields", func(t *testing.T) { + s := Segment{raw: []byte("BHS|^~\\&|A|B"), delims: d} + // type + separator + enc + A + B = 5 + if got := s.FieldCount(); got != 5 { + t.Errorf("FieldCount() = %d, want 5", got) + } + }) + + t.Run("BHS separator only", func(t *testing.T) { + s := Segment{raw: []byte("BHS|"), delims: d} + // type + separator = 2 + if got := s.FieldCount(); got != 2 { + t.Errorf("FieldCount() = %d, want 2", got) + } + }) + + t.Run("BHS type only", func(t *testing.T) { + s := Segment{raw: []byte("BHS"), delims: d} + // type only = 1 + if got := s.FieldCount(); got != 1 { + t.Errorf("FieldCount() = %d, want 1", got) + } + }) +} + func TestSegmentFieldCountEdgeCases(t *testing.T) { d := DefaultDelimiters() diff --git a/validate_test.go b/validate_test.go index d020bf0..fef4d74 100644 --- a/validate_test.go +++ b/validate_test.go @@ -414,6 +414,28 @@ func TestValidateDataTypes(t *testing.T) { }) } +func TestValidateDataTypeEmptyComponents(t *testing.T) { + // validateComposite is gated by len(dtDef.Components) > 0. + // An empty Components slice must be a no-op (no validation issues). + schema := &Schema{ + Segments: map[string]*SegmentDef{ + "PID": { + Fields: []FieldDef{ + {Index: 3, Name: "Patient ID", DataType: "ST_EMPTY"}, + }, + }, + }, + DataTypes: map[string]*DataTypeDef{ + "ST_EMPTY": {Components: []ComponentDef{}}, + }, + } + msg := parseTestMsg(t, "MSH|^~\\&|S|F|R|F|20240115||ADT^A01|1|P|2.5.1\rPID|||12345") + result := msg.Validate(schema) + if !result.Valid { + t.Errorf("expected valid, got issues: %v", result.Issues) + } +} + func TestValidatePrimitives(t *testing.T) { schema := &Schema{ Segments: map[string]*SegmentDef{