diff --git a/accessor_test.go b/accessor_test.go index b645bc3..b27d1bf 100644 --- a/accessor_test.go +++ b/accessor_test.go @@ -301,6 +301,9 @@ func TestParseLocationEdgeCases(t *testing.T) { {"EmptyFieldPart", "PID-", true}, {"TrailingGarbageAfterRep", "PID-3[1]x", true}, {"TrailingGarbageAfterSegIndex", "OBX(1)x-5", true}, + // ZeroComponent documents that ".0" is accepted and treated as + // "component not specified" (equivalent to omitting the component). + {"ZeroComponent", "PID-3.0", false}, } for _, tt := range tests { @@ -313,6 +316,31 @@ func TestParseLocationEdgeCases(t *testing.T) { } } +func TestGetVeryHighSegmentIndex(t *testing.T) { + raw := []byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rOBX|1|NM|code||42") + msg, err := ParseMessage(raw) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + // OBX(999) does not exist — should return empty string, no panic. + if got := msg.Get("OBX(999)-5"); got != "" { + t.Errorf("Get(OBX(999)-5) = %q, want empty", got) + } +} + +func TestGetSegmentWithNoFields(t *testing.T) { + // A segment consisting of only the type name with no field separator. + raw := []byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rZZZ") + msg, err := ParseMessage(raw) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + // ZZZ has no fields at all — accessing ZZZ-1 should return empty, no panic. + if got := msg.Get("ZZZ-1"); got != "" { + t.Errorf("Get(ZZZ-1) = %q, want empty", got) + } +} + func TestGetSubcomponentEmpty(t *testing.T) { raw := []byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") msg, _ := ParseMessage(raw) diff --git a/ack_test.go b/ack_test.go index 7bc3cb4..9db8eab 100644 --- a/ack_test.go +++ b/ack_test.go @@ -626,6 +626,68 @@ func TestAckWithErrors(t *testing.T) { }) } +func TestAckNoMSH(t *testing.T) { + msg := &Message{} + _, err := msg.Ack(AA, "X") + if err != ErrNoMSHSegment { + t.Errorf("err = %v, want ErrNoMSHSegment", err) + } +} + +func TestIsUpperAlpha(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"", true}, // vacuously true — loop body never executes + {"AB", true}, + {"PID", true}, + {"pid", false}, + {"Pid", false}, + {"PI1", false}, + {"ABCD", true}, // length not checked by isUpperAlpha itself + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := isUpperAlpha(tt.input); got != tt.want { + t.Errorf("isUpperAlpha(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestAppendERLBareSegmentBoundaries(t *testing.T) { + d := DefaultDelimiters() + tests := []struct { + location string + wantNil bool + want string + }{ + {"A", true, ""}, // len < 2 + {"AB", false, "AB^1"}, // len == 2, upper alpha + {"PID", false, "PID^1"}, // len == 3, upper alpha + {"ABCD", true, ""}, // len > 3 + {"pid", true, ""}, // lowercase — isUpperAlpha fails + {"P!D", true, ""}, // non-alpha — isUpperAlpha fails + } + + for _, tt := range tests { + t.Run(tt.location, func(t *testing.T) { + got := appendERL(nil, tt.location, d) + if tt.wantNil { + if len(got) != 0 { + t.Errorf("appendERL(nil, %q, d) = %q, want nil/empty", tt.location, got) + } + } else { + if string(got) != tt.want { + t.Errorf("appendERL(nil, %q, d) = %q, want %q", tt.location, got, tt.want) + } + } + }) + } +} + func BenchmarkAckWithErrors(b *testing.B) { raw := []byte("MSH|^~\\&|SEND_APP|SEND_FAC|RECV_APP|RECV_FAC|20240115||ADT^A01^ADT_A01|MSG001|P|2.5.1\rPID|||123||Smith^John") msg, _ := ParseMessage(raw) diff --git a/batch_test.go b/batch_test.go index 3cfc4f1..3e5c746 100644 --- a/batch_test.go +++ b/batch_test.go @@ -327,6 +327,48 @@ func TestJoinLinesEdgeCases(t *testing.T) { }) } +func TestParseBatchBodyEmpty(t *testing.T) { + // BHS and BTS present but no messages between them. + data := []byte("BHS|^~\\&|S|F|R|RF|20240101\rBTS|0") + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if batch.Header == nil { + t.Error("expected non-nil BHS header") + } + if batch.Trailer == nil { + t.Error("expected non-nil BTS trailer") + } + if len(batch.Messages) != 0 { + t.Errorf("expected 0 messages, got %d", len(batch.Messages)) + } +} + +func TestParseBatchNonMSHOnlyLines(t *testing.T) { + // No MSH, no BHS/BTS — only ZZZ segments. + data := []byte("ZZZ|some|data\rZZZ|more|data") + batch, err := ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + if len(batch.Messages) != 0 { + t.Errorf("expected 0 messages, got %d", len(batch.Messages)) + } +} + +func TestParseFileBTSNoBatch(t *testing.T) { + // BTS before any BHS: flushBatch is a no-op since no current batch. + data := []byte("FHS|^~\\&|S|F|R|RF|20240101\rBTS|0\rFTS|1") + file, err := ParseFile(data) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(file.Batches) != 0 { + t.Errorf("expected 0 batches, got %d", len(file.Batches)) + } +} + func TestParseBatchCustomDelimiters(t *testing.T) { data := []byte("BHS#@!$%SEND#FAC#REC#RFAC#20240101\rMSH#@!$%S#F#R#RF#20240101##ADT@A01#1#P#2.5.1\rPID#1##123\rBTS#1") batch, err := ParseBatch(data) diff --git a/delimiters_test.go b/delimiters_test.go index 7ad54b0..a9819fb 100644 --- a/delimiters_test.go +++ b/delimiters_test.go @@ -84,6 +84,17 @@ func TestExtractDelimiters(t *testing.T) { input: []byte("MSH|\r~\\&|"), wantErr: ErrInvalidDelimiter, }, + { + name: "LFRejected", + input: []byte("MSH|\n~\\&|"), + wantErr: ErrInvalidDelimiter, + }, + { + // TAB as field separator: data[3] = '\t', then standard encoding chars. + name: "TabAccepted", + input: []byte("MSH\t^~\\&\trest"), + want: Delimiters{Field: '\t', Component: '^', Repetition: '~', Escape: '\\', SubComponent: '&'}, + }, } for _, tt := range tests { @@ -115,6 +126,14 @@ func TestDelimitersValidate(t *testing.T) { if err := d.validate(); err != ErrDuplicateDelimiter { t.Errorf("expected ErrDuplicateDelimiter, got %v", err) } + + t.Run("LFDelimiter", func(t *testing.T) { + d2 := DefaultDelimiters() + d2.Field = '\n' + if err := d2.validate(); err != ErrInvalidDelimiter { + t.Errorf("expected ErrInvalidDelimiter for LF field sep, got %v", err) + } + }) } func TestNthSliceEdgeCases(t *testing.T) { @@ -145,6 +164,22 @@ func TestNthSliceEdgeCases(t *testing.T) { t.Errorf("got %q, want nil", got) } }) + + t.Run("DelimiterAtEnd", func(t *testing.T) { + data := []byte("hello|") + // n=0: the piece before the trailing delimiter. + got0 := nthSlice(data, '|', 0) + if string(got0) != "hello" { + t.Errorf("n=0: got %q, want %q", got0, "hello") + } + // n=1: the empty piece after the trailing delimiter — present but empty. + got1 := nthSlice(data, '|', 1) + if got1 == nil { + t.Error("n=1: got nil, want empty non-nil slice") + } else if len(got1) != 0 { + t.Errorf("n=1: got %q, want empty slice", got1) + } + }) } func TestNthRangeEdgeCases(t *testing.T) { diff --git a/doc.go b/doc.go index 53fe8cf..23c59b5 100644 --- a/doc.go +++ b/doc.go @@ -106,9 +106,24 @@ // hl7.Copy("PID-4", "PID-3"), // ) // -// Change constructors: Replace sets a value, Null sets the HL7 null (""), -// Omit clears a field, Move copies src to dst then clears src, and Copy -// copies src to dst while preserving the source. +// Field-level change constructors: Replace sets a value, Null sets the HL7 +// null (""), Omit clears a field, Move copies src to dst then clears src, +// and Copy copies src to dst while preserving the source. +// +// Segment-level change constructors add, remove, or reorder entire segments: +// +// updated, err := msg.Transform( +// hl7.DeleteSegment("NTE", 0), // delete first NTE (no-op if absent) +// hl7.DeleteAllSegments("NTE"), // delete all NTE segments +// hl7.InsertSegmentAfter("PID", 0, "ZPD"), // insert ZPD after first PID +// hl7.InsertSegmentBefore("OBX", -1, "ZPD"), // insert ZPD before last OBX +// hl7.AppendSegment("ZPD"), // append ZPD at end of message +// ) +// +// DeleteSegment and DeleteAllSegments are no-ops when the target is absent; +// both return ErrCannotDeleteMSH if given "MSH". InsertSegmentAfter and +// InsertSegmentBefore return ErrSegmentNotFound when the reference segment is +// absent. Segment-level and field-level changes compose freely in one call. // // TransformWith additionally re-encodes all delimiters to a new set: // diff --git a/error.go b/error.go index b0c3cfc..8414662 100644 --- a/error.go +++ b/error.go @@ -35,6 +35,7 @@ var ( ErrBatchStructure = errors.New("hl7: invalid batch structure") ErrMSHDelimiterField = errors.New("hl7: cannot modify MSH-1 or MSH-2 (delimiter fields)") ErrDelimiterMismatch = errors.New("hl7: delimiter mismatch between messages") + ErrCannotDeleteMSH = errors.New("hl7: cannot delete MSH segment") ) // ParseError provides detailed context about a parsing failure. diff --git a/message_test.go b/message_test.go index 92ede0c..3f84c01 100644 --- a/message_test.go +++ b/message_test.go @@ -17,6 +17,7 @@ package hl7 import ( "os" "path/filepath" + "sync" "testing" ) @@ -611,6 +612,154 @@ func BenchmarkParseMessageMultipleADD(b *testing.B) { } } +func TestSplitSegmentsDoubleTerminator(t *testing.T) { + tests := []struct { + name string + raw string + wantCount int + }{ + { + name: "DoubleCR", + raw: "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\r\rPID|1", + wantCount: 2, + }, + { + name: "LFCRReverseOrder", + raw: "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\n\rPID|1", + wantCount: 2, + }, + { + name: "BlankLineMiddle", + raw: "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\r\n\r\nPID|1", + wantCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := ParseMessage([]byte(tt.raw)) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + if got := len(msg.Segments()); got != tt.wantCount { + t.Errorf("Segments() count = %d, want %d", got, tt.wantCount) + } + }) + } +} + +func TestOutOfRangeAccessConsistency(t *testing.T) { + msg, err := ParseMessage([]byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123^^&OID&ISO^MRN")) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + seg := msg.Segments()[0] // MSH + + if !seg.Field(-1).IsEmpty() { + t.Error("Field(-1).IsEmpty() = false, want true") + } + if !seg.Field(9999).IsEmpty() { + t.Error("Field(9999).IsEmpty() = false, want true") + } + + field := seg.Field(9) + if !field.Rep(-1).IsEmpty() { + t.Error("Rep(-1).IsEmpty() = false, want true") + } + if !field.Rep(9999).IsEmpty() { + t.Error("Rep(9999).IsEmpty() = false, want true") + } + + rep := field.Rep(0) + // Component is 1-based; 0 is below minimum. + if !rep.Component(0).IsEmpty() { + t.Error("Component(0).IsEmpty() = false, want true") + } + if !rep.Component(9999).IsEmpty() { + t.Error("Component(9999).IsEmpty() = false, want true") + } + + comp := rep.Component(1) + if !comp.SubComponent(0).IsEmpty() { + t.Error("SubComponent(0).IsEmpty() = false, want true") + } + if !comp.SubComponent(9999).IsEmpty() { + t.Error("SubComponent(9999).IsEmpty() = false, want true") + } + + // Full chain from out-of-range should not panic. + got := seg.Field(-1).Rep(0).Component(1).String() + if got != "" { + t.Errorf("full out-of-range chain = %q, want empty string", got) + } +} + +func TestConcurrentRead(t *testing.T) { + msg, err := ParseMessage([]byte("MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123")) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + + const goroutines = 10 + const iters = 100 + + var wg sync.WaitGroup + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + for range iters { + _ = msg.Get("MSH-9") + for _, seg := range msg.Segments() { + _ = seg.Type() + } + } + }() + } + wg.Wait() +} + +func BenchmarkMergeADD(b *testing.B) { + base := "MSH|^~\\&|S|F|R|RF|20240101||ORU^R01|1|P|2.5.1\r" + + "PID|1||123||Smith^John|||F\r" + + "OBX|1|NM|718-7^Hemoglobin||13.5|g/dL|12.0-16.0||||F|||20240101\r" + + "OBX|2|NM|4544-3^Hematocrit||40.2|%|36.0-46.0||||F|||20240101\r" + + "OBX|3|NM|786-4^MCHC||33.6|g/dL|32.0-36.0||||F|||20240101\r" + + "OBX|4|NM|787-2^MCV||87.4|fL|80.0-100.0||||F|||20240101\r" + + "OBX|5|NM|788-0^MCH||29.3|pg|27.0-33.0||||F|||20240101" + + b.Run("WithoutADD", func(b *testing.B) { + data := []byte(base) + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _ = mergeADD(data, '|') + } + }) + + b.Run("WithADD", func(b *testing.B) { + data := []byte(base + "\rADD|extra_field_value") + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _ = mergeADD(data, '|') + } + }) + + b.Run("MultipleADD", func(b *testing.B) { + data := []byte(base + + "\rADD|extra1" + + "\rADD|extra2" + + "\rADD|extra3" + + "\rADD|extra4") + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _ = mergeADD(data, '|') + } + }) +} + func BenchmarkGetAccessor(b *testing.B) { data, err := os.ReadFile(filepath.Join("testdata", "oru_r01.hl7")) if err != nil { diff --git a/reader_test.go b/reader_test.go index 8c6a914..88f6432 100644 --- a/reader_test.go +++ b/reader_test.go @@ -365,6 +365,108 @@ func TestReaderReadRawMessageAutoRaw(t *testing.T) { } } +func TestReaderRawThreeMessages(t *testing.T) { + // This test exercises the io.MultiReader reconstruction path in readRaw + // twice on the same reader: once when transitioning from message 1 to 2, + // and again when transitioning from message 2 to 3. + raw := "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||111\n" + + "MSH|^~\\&|S|F|R|RF|20240102||ADT^A08|2|P|2.5.1\rPID|1||222\n" + + "MSH|^~\\&|S|F|R|RF|20240103||ADT^A09|3|P|2.5.1\rPID|1||333\n" + reader := NewReader(strings.NewReader(raw), WithMode(ModeRaw)) + + m1, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #1: %v", err) + } + if got := m1.Get("MSH-10"); got != "1" { + t.Errorf("msg1 MSH-10 = %q, want 1", got) + } + + m2, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #2: %v", err) + } + if got := m2.Get("MSH-10"); got != "2" { + t.Errorf("msg2 MSH-10 = %q, want 2", got) + } + + m3, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #3: %v", err) + } + if got := m3.Get("MSH-10"); got != "3" { + t.Errorf("msg3 MSH-10 = %q, want 3", got) + } + + _, err = reader.ReadMessage() + if err != io.EOF { + t.Errorf("expected io.EOF after all messages, got %v", err) + } +} + +func TestReaderAutoRawMultipleMessages(t *testing.T) { + // Same three-message stream as TestReaderRawThreeMessages, but using + // ModeAuto. detectMode should select raw on first call (input is not MLLP). + raw := "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||111\n" + + "MSH|^~\\&|S|F|R|RF|20240102||ADT^A08|2|P|2.5.1\rPID|1||222\n" + + "MSH|^~\\&|S|F|R|RF|20240103||ADT^A09|3|P|2.5.1\rPID|1||333\n" + reader := NewReader(strings.NewReader(raw)) // ModeAuto default + + m1, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #1: %v", err) + } + if got := m1.Get("MSH-10"); got != "1" { + t.Errorf("msg1 MSH-10 = %q, want 1", got) + } + + m2, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #2: %v", err) + } + if got := m2.Get("MSH-10"); got != "2" { + t.Errorf("msg2 MSH-10 = %q, want 2", got) + } + + m3, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #3: %v", err) + } + if got := m3.Get("MSH-10"); got != "3" { + t.Errorf("msg3 MSH-10 = %q, want 3", got) + } + + _, err = reader.ReadMessage() + if err != io.EOF { + t.Errorf("expected io.EOF after all messages, got %v", err) + } +} + +func TestReaderRawCROnlyTerminator(t *testing.T) { + // readRaw uses ReadBytes('\n') to delimit messages. When input uses only + // CR (no LF) as segment terminators, ReadBytes('\n') reads the entire + // stream as one block. Both messages end up in the first ReadMessage call. + // + // This documents the known limitation: CR-only streams are not suitable + // for multi-message raw mode. The first call returns a combined message + // and the second call returns io.EOF. + raw := "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||111\r" + + "MSH|^~\\&|S|F|R|RF|20240102||ADT^A08|2|P|2.5.1\rPID|1||222\r" + reader := NewReader(strings.NewReader(raw), WithMode(ModeRaw)) + + // First read returns a combined blob; it must not panic. + _, err := reader.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage #1 unexpectedly returned error: %v", err) + } + + // Second read must return EOF (entire stream was consumed). + _, err = reader.ReadMessage() + if err != io.EOF { + t.Errorf("expected io.EOF on second read, got %v", err) + } +} + func TestBufioReaderWrapper(t *testing.T) { data := []byte("hello world") br := bufio.NewReader(bytes.NewReader(data)) diff --git a/transform.go b/transform.go index fc4f20f..2e41bd9 100644 --- a/transform.go +++ b/transform.go @@ -25,6 +25,41 @@ type Change interface { applyChange() // unexported; seals the interface } +type deleteSegmentChange struct { + segType string + index int +} + +func (deleteSegmentChange) applyChange() {} + +type deleteAllSegmentsChange struct { + segType string +} + +func (deleteAllSegmentsChange) applyChange() {} + +type insertAfterChange struct { + afterType string + afterIndex int + newType string +} + +func (insertAfterChange) applyChange() {} + +type insertBeforeChange struct { + beforeType string + beforeIndex int + newType string +} + +func (insertBeforeChange) applyChange() {} + +type appendSegmentChange struct { + newType string +} + +func (appendSegmentChange) applyChange() {} + type replaceChange struct { location string value string @@ -75,6 +110,40 @@ func Move(dst, src string) Change { return moveChange{dst, src} } // The source value is preserved (unlike Move, which clears the source). func Copy(dst, src string) Change { return copyChange{dst, src} } +// DeleteSegment returns a Change that deletes the n-th occurrence (0-based) of +// segType. If the segment is not found, the change is a no-op. Attempting to +// delete MSH returns ErrCannotDeleteMSH. +func DeleteSegment(segType string, index int) Change { + return deleteSegmentChange{segType, index} +} + +// DeleteAllSegments returns a Change that deletes all occurrences of segType. +// If none are found, the change is a no-op. Attempting to delete MSH returns +// ErrCannotDeleteMSH. +func DeleteAllSegments(segType string) Change { + return deleteAllSegmentsChange{segType} +} + +// InsertSegmentAfter returns a Change that inserts a new empty segment of +// newType immediately after the n-th occurrence (0-based) of afterType. +// Use afterIndex = -1 to target the last occurrence. Returns +// ErrSegmentNotFound if afterType is not present in the message. +func InsertSegmentAfter(afterType string, afterIndex int, newType string) Change { + return insertAfterChange{afterType, afterIndex, newType} +} + +// InsertSegmentBefore returns a Change that inserts a new empty segment of +// newType immediately before the n-th occurrence (0-based) of beforeType. +// Use beforeIndex = -1 to target the last occurrence. Returns +// ErrSegmentNotFound if beforeType is not present in the message. +func InsertSegmentBefore(beforeType string, beforeIndex int, newType string) Change { + return insertBeforeChange{beforeType, beforeIndex, newType} +} + +// AppendSegment returns a Change that appends a new empty segment of newType +// at the end of the message. This change never returns an error. +func AppendSegment(newType string) Change { return appendSegmentChange{newType} } + // Transform applies the given changes to the message and returns a new Message. // The original message is not modified. The output uses the same delimiters as the source. func (m *Message) Transform(changes ...Change) (*Message, error) { @@ -180,6 +249,80 @@ func (w *workBuf) findSeg(typ []byte, idx int) int { return -1 } +// resolveSegIdx returns the workBuf index of the occurrence of typ selected by +// index. index == -1 selects the last occurrence; non-negative values select +// the n-th (0-based) occurrence. Returns -1 if not found. +func (w *workBuf) resolveSegIdx(typ []byte, index int) int { + if index >= 0 { + return w.findSeg(typ, index) + } + last := -1 + for i := range w.segs { + if seg := w.segBytes(i); len(seg) >= 3 && bytes.Equal(seg[:3], typ) { + last = i + } + } + return last +} + +// deleteSegAt removes the segment at segIdx from the buffer, including its +// trailing \r terminator. +func (w *workBuf) deleteSegAt(segIdx int) { + start := w.segs[segIdx].start + end := w.segs[segIdx].end + 1 // include the \r terminator + if end > len(w.data) { + end = len(w.data) + } + removed := end - start + + copy(w.data[start:], w.data[end:]) + w.data = w.data[:len(w.data)-removed] + + copy(w.segs[segIdx:], w.segs[segIdx+1:]) + w.segs = w.segs[:len(w.segs)-1] + + for i := segIdx; i < len(w.segs); i++ { + w.segs[i].start -= removed + w.segs[i].end -= removed + } +} + +// insertBefore inserts a new segment of newType immediately before the segment +// at position pos. If pos == len(w.segs), the segment is appended at the end. +func (w *workBuf) insertBefore(pos int, newType []byte) { + var bytePos int + if pos < len(w.segs) { + bytePos = w.segs[pos].start + } else { + bytePos = len(w.data) + } + + shift := len(newType) + 1 // newType bytes + \r + newLen := len(w.data) + shift + + if cap(w.data) >= newLen { + w.data = w.data[:newLen] + copy(w.data[bytePos+shift:], w.data[bytePos:newLen-shift]) + } else { + buf := make([]byte, newLen, newLen+newLen/4) + copy(buf[:bytePos], w.data[:bytePos]) + copy(buf[bytePos+shift:], w.data[bytePos:]) + w.data = buf + } + copy(w.data[bytePos:], newType) + w.data[bytePos+len(newType)] = SegmentTerminator + + newBound := segBound{bytePos, bytePos + len(newType)} + w.segs = append(w.segs, segBound{}) + copy(w.segs[pos+1:], w.segs[pos:]) + w.segs[pos] = newBound + + for i := pos + 1; i < len(w.segs); i++ { + w.segs[i].start += shift + w.segs[i].end += shift + } +} + func (w *workBuf) createSeg(typ []byte, idx int, delims Delimiters) int { count := 0 for i := range w.segs { @@ -497,6 +640,47 @@ func applyOneChange(w *workBuf, delims Delimiters, c Change) error { } applyValueAtLocation(w, delims, dstLoc, copyVal) + + case deleteSegmentChange: + if ch.segType == "MSH" { + return ErrCannotDeleteMSH + } + if segIdx := w.findSeg([]byte(ch.segType), ch.index); segIdx >= 0 { + w.deleteSegAt(segIdx) + } + + case deleteAllSegmentsChange: + if ch.segType == "MSH" { + return ErrCannotDeleteMSH + } + // Collect indices in a single pass, then delete right-to-left to keep + // earlier indices valid after each deletion. + var indices []int + for i := range w.segs { + if seg := w.segBytes(i); len(seg) >= 3 && string(seg[:3]) == ch.segType { + indices = append(indices, i) + } + } + for i := len(indices) - 1; i >= 0; i-- { + w.deleteSegAt(indices[i]) + } + + case insertAfterChange: + afterIdx := w.resolveSegIdx([]byte(ch.afterType), ch.afterIndex) + if afterIdx < 0 { + return ErrSegmentNotFound + } + w.insertBefore(afterIdx+1, []byte(ch.newType)) + + case insertBeforeChange: + beforeIdx := w.resolveSegIdx([]byte(ch.beforeType), ch.beforeIndex) + if beforeIdx < 0 { + return ErrSegmentNotFound + } + w.insertBefore(beforeIdx, []byte(ch.newType)) + + case appendSegmentChange: + w.insertBefore(len(w.segs), []byte(ch.newType)) } return nil } diff --git a/transform_test.go b/transform_test.go index 4d8e8e9..0245d7f 100644 --- a/transform_test.go +++ b/transform_test.go @@ -1050,3 +1050,526 @@ func TestTransformMoveMSHDelimRejected(t *testing.T) { t.Errorf("expected ErrMSHDelimiterField for src MSH-2, got %v", err) } } + +// segTypes returns the type of each segment in the message, in order. +func segTypes(msg *Message) []string { + segs := msg.Segments() + types := make([]string, len(segs)) + for i, s := range segs { + types[i] = s.Type() + } + return types +} + +func TestDeleteSegment(t *testing.T) { + t.Run("MiddleSegment", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rNTE|1||Note\rOBX|1|NM|code||val") + result, err := msg.Transform(DeleteSegment("NTE", 0)) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("NotFound_NoOp", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + result, err := msg.Transform(DeleteSegment("ZZZ", 0)) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("DeleteMSH_Error", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1") + _, err := msg.Transform(DeleteSegment("MSH", 0)) + if !errors.Is(err, ErrCannotDeleteMSH) { + t.Errorf("expected ErrCannotDeleteMSH, got %v", err) + } + }) + + t.Run("FirstOfMultiple", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rNTE|1||First\rNTE|2||Second\rPID|1||123") + result, err := msg.Transform(DeleteSegment("NTE", 0)) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "NTE", "PID"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + // Remaining NTE should be the second one. + if got := result.Get("NTE-3"); got != "Second" { + t.Errorf("NTE-3 = %q, want %q", got, "Second") + } + }) + + t.Run("LastOfMultiple", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rNTE|1||First\rNTE|2||Second\rPID|1||123") + result, err := msg.Transform(DeleteSegment("NTE", 1)) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "NTE", "PID"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + // Remaining NTE should be the first one. + if got := result.Get("NTE-3"); got != "First" { + t.Errorf("NTE-3 = %q, want %q", got, "First") + } + }) + + t.Run("IndexOutOfRange_NoOp", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rNTE|1||Note") + result, err := msg.Transform(DeleteSegment("NTE", 5)) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + if len(got) != 2 { + t.Errorf("segments = %v, want [MSH NTE]", got) + } + }) +} + +func TestDeleteAllSegments(t *testing.T) { + t.Run("DeleteAllNTE", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rNTE|1||First\rNTE|2||Second\rNTE|3||Third\rOBX|1|NM|code||val") + result, err := msg.Transform(DeleteAllSegments("NTE")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("TypeNotPresent_NoOp", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + result, err := msg.Transform(DeleteAllSegments("ZZZ")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + if len(segTypes(result)) != 2 { + t.Errorf("segments = %v, want [MSH PID]", segTypes(result)) + } + }) + + t.Run("DeleteMSH_Error", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1") + _, err := msg.Transform(DeleteAllSegments("MSH")) + if !errors.Is(err, ErrCannotDeleteMSH) { + t.Errorf("expected ErrCannotDeleteMSH, got %v", err) + } + }) + + t.Run("NonTargetSegmentsUntouched", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rNTE|1||Note\rOBX|1|NM|code||42") + result, err := msg.Transform(DeleteAllSegments("NTE")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + if got := result.Get("PID-3"); got != "123" { + t.Errorf("PID-3 = %q, want %q", got, "123") + } + if got := result.Get("OBX-5"); got != "42" { + t.Errorf("OBX-5 = %q, want %q", got, "42") + } + }) +} + +func TestInsertSegmentAfter(t *testing.T) { + t.Run("AfterFirstPID", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rOBX|1|NM|code||val") + result, err := msg.Transform(InsertSegmentAfter("PID", 0, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID", "ZPD", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("NotFound_Error", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + _, err := msg.Transform(InsertSegmentAfter("ZZZ", 0, "ZPD")) + if !errors.Is(err, ErrSegmentNotFound) { + t.Errorf("expected ErrSegmentNotFound, got %v", err) + } + }) + + t.Run("LastOccurrence_NegativeIndex", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rOBX|1|NM|code||v1\rOBX|2|NM|code||v2\rPID|1||123") + result, err := msg.Transform(InsertSegmentAfter("OBX", -1, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "OBX", "OBX", "ZPD", "PID"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("ChainWithReplace", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + result, err := msg.Transform( + InsertSegmentAfter("PID", 0, "ZPD"), + Replace("ZPD-1", "42"), + ) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + if got := result.Get("ZPD-1"); got != "42" { + t.Errorf("ZPD-1 = %q, want %q", got, "42") + } + }) + + t.Run("AfterLastSegment", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + result, err := msg.Transform(InsertSegmentAfter("PID", 0, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + if len(got) != 3 || got[2] != "ZPD" { + t.Errorf("segments = %v, want [MSH PID ZPD]", got) + } + }) +} + +func TestInsertSegmentBefore(t *testing.T) { + t.Run("BeforeFirstOBX", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rOBX|1|NM|code||val") + result, err := msg.Transform(InsertSegmentBefore("OBX", 0, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID", "ZPD", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("NotFound_Error", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + _, err := msg.Transform(InsertSegmentBefore("ZZZ", 0, "ZPD")) + if !errors.Is(err, ErrSegmentNotFound) { + t.Errorf("expected ErrSegmentNotFound, got %v", err) + } + }) + + t.Run("LastOccurrence_NegativeIndex", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rNTE|1||First\rNTE|2||Second\rOBX|1|NM|code||val") + result, err := msg.Transform(InsertSegmentBefore("NTE", -1, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "NTE", "ZPD", "NTE", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("BeforeFirstSegment", func(t *testing.T) { + // Inserting before MSH — MSH is at index 0, so new segment is at index 0. + // But MSH must remain first for ParseMessage to succeed, so this inserts before MSH. + // We test with a non-MSH segment instead to keep message valid. + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rOBX|1|NM|code||val") + result, err := msg.Transform(InsertSegmentBefore("PID", 0, "ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "ZPD", "PID", "OBX"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) +} + +func TestAppendSegment(t *testing.T) { + t.Run("AppendsAfterLast", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rOBX|1|NM|code||val") + result, err := msg.Transform(AppendSegment("ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "PID", "OBX", "ZPD"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("MinimalMSHOnly", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1") + result, err := msg.Transform(AppendSegment("ZPD")) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + if len(got) != 2 || got[1] != "ZPD" { + t.Errorf("segments = %v, want [MSH ZPD]", got) + } + }) + + t.Run("ChainWithFieldChanges", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + result, err := msg.Transform( + AppendSegment("ZPD"), + Replace("ZPD-3", "CustomValue"), + ) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + if got := result.Get("ZPD-3"); got != "CustomValue" { + t.Errorf("ZPD-3 = %q, want %q", got, "CustomValue") + } + got := segTypes(result) + if len(got) != 3 || got[2] != "ZPD" { + t.Errorf("segments = %v, want [MSH PID ZPD]", got) + } + }) + + t.Run("MultipleAppends", func(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1") + result, err := msg.Transform( + AppendSegment("ZPD"), + AppendSegment("ZAP"), + ) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + got := segTypes(result) + want := []string{"MSH", "ZPD", "ZAP"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) +} + +func TestSegmentOpsWithFieldChanges(t *testing.T) { + msg := parseTestMessage(t, "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123") + + result, err := msg.Transform( + InsertSegmentAfter("PID", 0, "ZPD"), + Replace("ZPD-1", "InsertedValue"), + Replace("PID-3", "456"), + ) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + got := segTypes(result) + want := []string{"MSH", "PID", "ZPD"} + if len(got) != len(want) { + t.Fatalf("segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("segment[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if v := result.Get("ZPD-1"); v != "InsertedValue" { + t.Errorf("ZPD-1 = %q, want %q", v, "InsertedValue") + } + if v := result.Get("PID-3"); v != "456" { + t.Errorf("PID-3 = %q, want %q", v, "456") + } +} + +func TestSegmentOpsPreserveOriginal(t *testing.T) { + raw := "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\rNTE|1||Note" + msg := parseTestMessage(t, raw) + + _, err := msg.Transform( + DeleteSegment("NTE", 0), + InsertSegmentAfter("PID", 0, "ZPD"), + AppendSegment("ZAP"), + ) + if err != nil { + t.Fatalf("Transform failed: %v", err) + } + + // Original must be unmodified. + got := segTypes(msg) + want := []string{"MSH", "PID", "NTE"} + if len(got) != len(want) { + t.Fatalf("original segments = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("original segment[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func BenchmarkDeleteSegment(b *testing.B) { + data, err := os.ReadFile(filepath.Join("testdata", "oru_r01.hl7")) + if err != nil { + b.Fatalf("failed to read test file: %v", err) + } + // Append an NTE segment to the ORU_R01 message for deletion testing. + data = append(data, []byte("\rNTE|1||BenchmarkNote")...) + + msg, err := ParseMessage(data) + if err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, _ = msg.Transform(DeleteSegment("NTE", 0)) + } +} + +func BenchmarkDeleteAllSegments(b *testing.B) { + // Build a message with 3 NTE segments. + raw := "MSH|^~\\&|S|F|R|RF|20240101||ADT^A01|1|P|2.5.1\rPID|1||123\r" + + "NTE|1||First\rNTE|2||Second\rNTE|3||Third\rOBX|1|NM|code||val" + + msg, err := ParseMessage([]byte(raw)) + if err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, _ = msg.Transform(DeleteAllSegments("NTE")) + } +} + +func BenchmarkInsertSegmentAfter(b *testing.B) { + data, err := os.ReadFile(filepath.Join("testdata", "oru_r01.hl7")) + if err != nil { + b.Fatalf("failed to read test file: %v", err) + } + + msg, err := ParseMessage(data) + if err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, _ = msg.Transform(InsertSegmentAfter("PID", 0, "ZPD")) + } +} + +func BenchmarkInsertSegmentBefore(b *testing.B) { + data, err := os.ReadFile(filepath.Join("testdata", "oru_r01.hl7")) + if err != nil { + b.Fatalf("failed to read test file: %v", err) + } + + msg, err := ParseMessage(data) + if err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, _ = msg.Transform(InsertSegmentBefore("OBX", 0, "OBR")) + } +} + +func BenchmarkAppendSegment(b *testing.B) { + data, err := os.ReadFile(filepath.Join("testdata", "oru_r01.hl7")) + if err != nil { + b.Fatalf("failed to read test file: %v", err) + } + + msg, err := ParseMessage(data) + if err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + _, _ = msg.Transform(AppendSegment("ZPD")) + } +} diff --git a/validate_test.go b/validate_test.go index 4541395..d020bf0 100644 --- a/validate_test.go +++ b/validate_test.go @@ -1432,6 +1432,109 @@ func TestValidateComponentDataTypeFormat(t *testing.T) { } } +func TestValidateMissingMessageDefDoesNotSuppressContentValidation(t *testing.T) { + // Phase 1 (structure) and Phase 2 (content) are independent: an unknown + // message structure emits CodeUnknownStructure but must not prevent field + // validation from running. + schema := &Schema{ + Messages: map[string]*MessageDef{ + "ADT_A01": { + Elements: []Element{ + {Segment: "MSH", Min: 1, Max: 1}, + {Segment: "PID", Min: 1, Max: 1}, + }, + }, + }, + Segments: map[string]*SegmentDef{ + "PID": { + Fields: []FieldDef{ + {Index: 3, Name: "Patient ID", Required: true}, + }, + }, + }, + } + + // MSH-9.3 is "ORU_R01" which is not in the Messages map. + // PID-3 is empty, so CodeRequiredField should fire in Phase 2. + msg := parseTestMsg(t, + "MSH|^~\\&|S|F|R|F|20240115||ORU^R01^ORU_R01|1|P|2.5.1\rPID|||") + result := msg.Validate(schema) + if result.Valid { + t.Fatal("expected invalid") + } + + unknownIssues := issuesByCode(result, CodeUnknownStructure) + if len(unknownIssues) == 0 { + t.Error("expected at least one UNKNOWN_STRUCTURE issue from Phase 1") + } + + requiredIssues := issuesByCode(result, CodeRequiredField) + if len(requiredIssues) == 0 { + t.Error("expected at least one REQUIRED_FIELD issue from Phase 2") + } +} + +func TestValidateGroupProbeSnapshotRestore(t *testing.T) { + // The validator probes for additional group iterations by calling + // matchElements. If the probe does not consume any segments, spurious + // issues (e.g., "required segment missing") generated during probing must + // be rolled back via snapshotIssues/restoreIssues. This test verifies that + // exactly one group iteration (OBR + OBX) produces no false-positive issues. + schema := &Schema{ + Messages: map[string]*MessageDef{ + "TST_T01": { + Elements: []Element{ + {Segment: "MSH", Min: 1, Max: 1}, + {Group: "ORDER", Min: 1, Max: -1, Elements: []Element{ + {Segment: "OBR", Min: 1, Max: 1}, + {Segment: "OBX", Min: 1, Max: 1}, + }}, + }, + }, + }, + } + + // Exactly one OBR + OBX group. Probing for a second iteration should + // generate spurious issues for the missing OBR/OBX but roll them back. + msg := parseTestMsg(t, + "MSH|^~\\&|S|F|R|F|20240115||TST^T01^TST_T01|1|P|2.5.1\rOBR|1\rOBX|1") + result := msg.Validate(schema) + if !result.Valid { + t.Errorf("expected valid (snapshot/restore must roll back probe issues), got: %v", result.Issues) + } + if len(result.Issues) != 0 { + t.Errorf("expected 0 issues, got %d: %v", len(result.Issues), result.Issues) + } +} + +func TestValidateADDNotVisible(t *testing.T) { + // After ParseMessage, ADD is merged into the preceding segment and is no + // longer present as a standalone segment. The validator must not see an + // ADD segment and must not emit CodeUnexpectedSegment for it. + schema := &Schema{ + Messages: map[string]*MessageDef{ + "TST_T01": { + Elements: []Element{ + {Segment: "MSH", Min: 1, Max: 1}, + {Segment: "OBX", Min: 1, Max: -1}, + }, + }, + }, + } + + // OBX followed by ADD — ParseMessage merges ADD into OBX. + msg := parseTestMsg(t, + "MSH|^~\\&|S|F|R|F|20240115||TST^T01^TST_T01|1|P|2.5.1\rOBX|1|TX|code||part1\rADD|part2") + result := msg.Validate(schema) + if !result.Valid { + t.Errorf("expected valid (ADD merged, not visible to validator), got: %v", result.Issues) + } + unexpectedIssues := issuesByCode(result, CodeUnexpectedSegment) + if len(unexpectedIssues) != 0 { + t.Errorf("expected no UNEXPECTED_SEGMENT issues, got: %v", unexpectedIssues) + } +} + func TestValidateRepeatingFieldLocation(t *testing.T) { schema := &Schema{ Segments: map[string]*SegmentDef{