From 839405d67aa94fd2dc8f75411c84a9ae07079d1b Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 00:10:44 +0100 Subject: [PATCH 1/3] fix: align parser with old extension data format The parser was not correctly handling the data format produced by the old OCAP extension (OcapReplaySaver2). This fixes three format mismatches: 1. Marker positions: Old extension produces [frameNum, [x,y], dir, ?alpha] but parser expected [[x,y,z], frameNum, dir, alpha]. Added detection to handle both formats. 2. Side index mapping: Old extension uses BIS_fnc_sideID where 0=EAST, 1=WEST, but parser had them swapped. Also added support for -1=GLOBAL. 3. Event format: Old extension uses [frameNum, type, victimId, [killerId, weaponName], distance] for killed/hit events, but parser expected separate fields. Added format detection based on whether index 3 is an array. --- internal/storage/converter.go | 8 ++- internal/storage/converter_test.go | 7 +- internal/storage/parser_test.go | 9 +-- internal/storage/parser_v1.go | 105 ++++++++++++++++++++++------- 4 files changed, 97 insertions(+), 32 deletions(-) diff --git a/internal/storage/converter.go b/internal/storage/converter.go index ce61fff2..c317b7b6 100644 --- a/internal/storage/converter.go +++ b/internal/storage/converter.go @@ -145,16 +145,20 @@ func stringToSide(s string) pbv1.Side { } } +// sideIndexToSide converts a side index to protobuf Side enum +// Old extension uses BIS_fnc_sideID: -1=global, 0=EAST, 1=WEST, 2=RESISTANCE, 3=CIVILIAN func sideIndexToSide(idx int) pbv1.Side { switch idx { case 0: - return pbv1.Side_SIDE_WEST - case 1: return pbv1.Side_SIDE_EAST + case 1: + return pbv1.Side_SIDE_WEST case 2: return pbv1.Side_SIDE_GUER case 3: return pbv1.Side_SIDE_CIV + case -1: + return pbv1.Side_SIDE_GLOBAL default: return pbv1.Side_SIDE_UNKNOWN } diff --git a/internal/storage/converter_test.go b/internal/storage/converter_test.go index c798cd0a..d7966254 100644 --- a/internal/storage/converter_test.go +++ b/internal/storage/converter_test.go @@ -521,17 +521,18 @@ func TestConverter_StringToSide(t *testing.T) { } func TestSideIndexToSide(t *testing.T) { + // Old extension uses BIS_fnc_sideID: -1=global, 0=EAST, 1=WEST, 2=RESISTANCE, 3=CIVILIAN tests := []struct { name string input int want pbv1.Side }{ - {"WEST index 0", 0, pbv1.Side_SIDE_WEST}, - {"EAST index 1", 1, pbv1.Side_SIDE_EAST}, + {"EAST index 0", 0, pbv1.Side_SIDE_EAST}, + {"WEST index 1", 1, pbv1.Side_SIDE_WEST}, {"GUER index 2", 2, pbv1.Side_SIDE_GUER}, {"CIV index 3", 3, pbv1.Side_SIDE_CIV}, {"UNKNOWN index 4", 4, pbv1.Side_SIDE_UNKNOWN}, - {"UNKNOWN negative", -1, pbv1.Side_SIDE_UNKNOWN}, + {"GLOBAL negative", -1, pbv1.Side_SIDE_GLOBAL}, {"UNKNOWN large", 100, pbv1.Side_SIDE_UNKNOWN}, } diff --git a/internal/storage/parser_test.go b/internal/storage/parser_test.go index f67e665d..e13cb196 100644 --- a/internal/storage/parser_test.go +++ b/internal/storage/parser_test.go @@ -307,7 +307,7 @@ func TestParserV1_Parse_Markers(t *testing.T) { 10.0, // endFrame 0.0, // playerId "ColorBlufor", // color - 0.0, // sideIndex (0 = WEST) + 1.0, // sideIndex (1 = WEST per BIS_fnc_sideID) []interface{}{[]interface{}{100.0, 200.0, 0.0}}, // positions []interface{}{1.0, 1.0}, // size "ICON", // shape @@ -696,16 +696,17 @@ func TestParserV1_calculateEndFrame(t *testing.T) { } func TestSideIndexToString(t *testing.T) { + // Old extension uses BIS_fnc_sideID: -1=global, 0=EAST, 1=WEST, 2=RESISTANCE, 3=CIVILIAN tests := []struct { input int want string }{ - {0, "WEST"}, - {1, "EAST"}, + {0, "EAST"}, + {1, "WEST"}, {2, "GUER"}, {3, "CIV"}, {4, ""}, - {-1, ""}, + {-1, "GLOBAL"}, {100, ""}, } diff --git a/internal/storage/parser_v1.go b/internal/storage/parser_v1.go index 00d6fbab..12776a93 100644 --- a/internal/storage/parser_v1.go +++ b/internal/storage/parser_v1.go @@ -139,29 +139,60 @@ func (p *ParserV1) parseEvent(evtArr []interface{}) *Event { } // Parse additional fields based on event type - // Common format: [frameNum, "type", sourceId, targetId, ...] - if len(evtArr) > 2 { - event.SourceID = uint32(toFloat64(evtArr[2])) - } + // Old extension format for killed/hit: [frameNum, "type", victimId, [killerId, weaponName], distance] + // Alternative format: [frameNum, "type", sourceId, targetId, weapon, distance] + + // First, detect format by checking if index 3 is an array + isOldExtensionFormat := false if len(evtArr) > 3 { - event.TargetID = uint32(toFloat64(evtArr[3])) + _, isOldExtensionFormat = evtArr[3].([]interface{}) } - if len(evtArr) > 4 { - // Could be weapon name, message, or distance depending on event type - switch v := evtArr[4].(type) { - case string: - if event.Type == "hit" || event.Type == "killed" { - event.Weapon = v - } else { - event.Message = v + + if isOldExtensionFormat { + // Old extension format: [frameNum, "type", victimId, [killerId, weaponName], distance] + if len(evtArr) > 2 { + event.TargetID = uint32(toFloat64(evtArr[2])) // victimId + } + if len(evtArr) > 3 { + if killerArr, ok := evtArr[3].([]interface{}); ok { + if len(killerArr) > 0 { + event.SourceID = uint32(toFloat64(killerArr[0])) // killerId + } + if len(killerArr) > 1 { + event.Weapon = toString(killerArr[1]) // weaponName + } } - case float64: - event.Distance = float32(v) } - } - if len(evtArr) > 5 { - if d, ok := evtArr[5].(float64); ok { - event.Distance = float32(d) + if len(evtArr) > 4 { + if d, ok := evtArr[4].(float64); ok { + event.Distance = float32(d) + } + } + } else { + // Alternative format: [frameNum, "type", sourceId, targetId, weapon, distance] + if len(evtArr) > 2 { + event.SourceID = uint32(toFloat64(evtArr[2])) + } + if len(evtArr) > 3 { + event.TargetID = uint32(toFloat64(evtArr[3])) + } + if len(evtArr) > 4 { + // Could be weapon name, message, or distance depending on event type + switch v := evtArr[4].(type) { + case string: + if event.Type == "hit" || event.Type == "killed" { + event.Weapon = v + } else { + event.Message = v + } + case float64: + event.Distance = float32(v) + } + } + if len(evtArr) > 5 { + if d, ok := evtArr[5].(float64); ok { + event.Distance = float32(d) + } } } @@ -221,7 +252,10 @@ func (p *ParserV1) parseMarker(markerArr []interface{}) *MarkerDef { // parseMarkerPosition converts position data to MarkerPosition func (p *ParserV1) parseMarkerPosition(pos interface{}) *MarkerPosition { - // Position format can be: [x, y, z] or [[x, y, z], frameNum, direction, alpha] + // Position formats: + // - Old extension format: [frameNum, [x, y], direction, ?alpha] + // - Alternative format: [[x, y, z], frameNum, direction, alpha] + // - Simple format: [x, y, z] arr, ok := pos.([]interface{}) if !ok || len(arr) == 0 { return nil @@ -229,7 +263,29 @@ func (p *ParserV1) parseMarkerPosition(pos interface{}) *MarkerPosition { mp := &MarkerPosition{} - // Check if first element is a position array + // Check if second element is a position array (old extension format) + if len(arr) > 1 { + if posArr, ok := arr[1].([]interface{}); ok { + // Old extension format: [frameNum, [x, y], direction, ?alpha] + mp.FrameNum = uint32(toFloat64(arr[0])) + if len(posArr) >= 2 { + mp.PosX = float32(toFloat64(posArr[0])) + mp.PosY = float32(toFloat64(posArr[1])) + if len(posArr) > 2 { + mp.PosZ = float32(toFloat64(posArr[2])) + } + } + if len(arr) > 2 { + mp.Direction = float32(toFloat64(arr[2])) + } + if len(arr) > 3 { + mp.Alpha = float32(toFloat64(arr[3])) + } + return mp + } + } + + // Check if first element is a position array (alternative format) if posArr, ok := arr[0].([]interface{}); ok { // Format: [[x, y, z], frameNum, direction, alpha] if len(posArr) >= 2 { @@ -337,16 +393,19 @@ func (p *ParserV1) collectEntityPositions(em map[string]interface{}, entityID ui } // sideIndexToString converts a side index to side string +// Old extension uses BIS_fnc_sideID: -1=global, 0=EAST, 1=WEST, 2=RESISTANCE, 3=CIVILIAN func sideIndexToString(idx int) string { switch idx { case 0: - return "WEST" - case 1: return "EAST" + case 1: + return "WEST" case 2: return "GUER" case 3: return "CIV" + case -1: + return "GLOBAL" default: return "" } From 7af8d034957da45cd4ea5608f0e0b8c781dbb141 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 00:17:15 +0100 Subject: [PATCH 2/3] test: add coverage for old extension data formats Add comprehensive test coverage for: - Old extension marker position format [frameNum, [x,y], dir, ?alpha] - Old extension killed/hit event format [frameNum, type, victimId, [killerId, weapon], distance] - Integration tests parsing markers and events with old extension format - Both alternative and old extension formats to ensure backward compatibility --- internal/storage/parser_test.go | 291 ++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/internal/storage/parser_test.go b/internal/storage/parser_test.go index e13cb196..6d2a5a1c 100644 --- a/internal/storage/parser_test.go +++ b/internal/storage/parser_test.go @@ -367,6 +367,140 @@ func TestParserV1_Parse_Markers(t *testing.T) { } } +func TestParserV1_Parse_Markers_OldExtensionFormat(t *testing.T) { + p := &ParserV1{} + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 100.0, + "captureDelay": 1.0, + "entities": []interface{}{}, + "Markers": []interface{}{ + []interface{}{ + "o_inf", // type (old extension marker type) + "Enemy Squad", // text + 0.0, // startFrame + -1.0, // endFrame (-1 = not deleted, converted to frame count) + 5.0, // playerId + "0000FF", // color (hex without #) + 0.0, // sideIndex (0 = EAST per BIS_fnc_sideID) + []interface{}{ // positions in old extension format + []interface{}{0.0, []interface{}{3915.44, 1971.98}, 180.0}, // [frameNum, [x,y], dir] + []interface{}{50.0, []interface{}{3882.53, 2041.32}, 270.0, 100.0}, // [frameNum, [x,y], dir, alpha] + }, + []interface{}{1.0, 1.0}, // size + "ICON", // shape + "Solid", // brush + }, + }, + } + + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + if len(result.Markers) != 1 { + t.Fatalf("len(Markers) = %d, want %d", len(result.Markers), 1) + } + + m := result.Markers[0] + if m.Type != "o_inf" { + t.Errorf("Marker.Type = %q, want %q", m.Type, "o_inf") + } + if m.Side != "EAST" { + t.Errorf("Marker.Side = %q, want %q (sideIndex 0 = EAST)", m.Side, "EAST") + } + if m.Color != "0000FF" { + t.Errorf("Marker.Color = %q, want %q", m.Color, "0000FF") + } + + if len(m.Positions) != 2 { + t.Fatalf("len(Marker.Positions) = %d, want %d", len(m.Positions), 2) + } + + // Check first position + pos1 := m.Positions[0] + if pos1.FrameNum != 0 { + t.Errorf("Positions[0].FrameNum = %d, want %d", pos1.FrameNum, 0) + } + if pos1.PosX != 3915.44 { + t.Errorf("Positions[0].PosX = %v, want %v", pos1.PosX, 3915.44) + } + if pos1.PosY != 1971.98 { + t.Errorf("Positions[0].PosY = %v, want %v", pos1.PosY, 1971.98) + } + if pos1.Direction != 180.0 { + t.Errorf("Positions[0].Direction = %v, want %v", pos1.Direction, 180.0) + } + + // Check second position with alpha + pos2 := m.Positions[1] + if pos2.FrameNum != 50 { + t.Errorf("Positions[1].FrameNum = %d, want %d", pos2.FrameNum, 50) + } + if pos2.Alpha != 100.0 { + t.Errorf("Positions[1].Alpha = %v, want %v", pos2.Alpha, 100.0) + } +} + +func TestParserV1_Parse_Events_OldExtensionFormat(t *testing.T) { + p := &ParserV1{} + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 100.0, + "captureDelay": 1.0, + "entities": []interface{}{}, + "events": []interface{}{ + // Old extension killed event format + []interface{}{404.0, "killed", 84.0, []interface{}{83.0, "AKS-74N"}, 10.0}, + // Old extension hit event format + []interface{}{3652.0, "killed", 160.0, []interface{}{83.0, "PKP Pecheneg"}, 80.0}, + // Connected event (same format) + []interface{}{0.0, "connected", "[RMC] DoS"}, + // Disconnected event + []interface{}{3312.0, "disconnected", "[VRG] mEss1a"}, + }, + } + + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + if len(result.Events) != 4 { + t.Fatalf("len(Events) = %d, want %d", len(result.Events), 4) + } + + // Check first killed event + evt := result.Events[0] + if evt.Type != "killed" { + t.Errorf("Event[0].Type = %q, want %q", evt.Type, "killed") + } + if evt.TargetID != 84 { + t.Errorf("Event[0].TargetID = %d, want %d (victimId)", evt.TargetID, 84) + } + if evt.SourceID != 83 { + t.Errorf("Event[0].SourceID = %d, want %d (killerId)", evt.SourceID, 83) + } + if evt.Weapon != "AKS-74N" { + t.Errorf("Event[0].Weapon = %q, want %q", evt.Weapon, "AKS-74N") + } + if evt.Distance != 10.0 { + t.Errorf("Event[0].Distance = %v, want %v", evt.Distance, 10.0) + } + + // Check connected event + evt = result.Events[2] + if evt.Type != "connected" { + t.Errorf("Event[2].Type = %q, want %q", evt.Type, "connected") + } + if evt.Message != "[RMC] DoS" { + t.Errorf("Event[2].Message = %q, want %q", evt.Message, "[RMC] DoS") + } +} + func TestParserV1_Parse_Times(t *testing.T) { p := &ParserV1{} data := map[string]interface{}{ @@ -592,6 +726,107 @@ func TestParserV1_parseEvent_EdgeCases(t *testing.T) { t.Errorf("Message = %q, want empty string", evt.Message) } }) + + // Old extension format tests + t.Run("old extension killed event [frameNum, type, victimId, [killerId, weapon], distance]", func(t *testing.T) { + // Old extension produces: [frameNum, "killed", victimId, [killerId, weaponName], distance] + evt := p.parseEvent([]interface{}{ + 404.0, // frameNum + "killed", // type + 84.0, // victimId (TargetID) + []interface{}{83.0, "AKS-74N"}, // [killerId, weaponName] + 10.0, // distance + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.FrameNum != 404 { + t.Errorf("FrameNum = %d, want %d", evt.FrameNum, 404) + } + if evt.Type != "killed" { + t.Errorf("Type = %q, want %q", evt.Type, "killed") + } + if evt.TargetID != 84 { + t.Errorf("TargetID = %d, want %d (victimId)", evt.TargetID, 84) + } + if evt.SourceID != 83 { + t.Errorf("SourceID = %d, want %d (killerId)", evt.SourceID, 83) + } + if evt.Weapon != "AKS-74N" { + t.Errorf("Weapon = %q, want %q", evt.Weapon, "AKS-74N") + } + if evt.Distance != 10.0 { + t.Errorf("Distance = %v, want %v", evt.Distance, 10.0) + } + }) + + t.Run("old extension hit event [frameNum, type, victimId, [shooterId, weapon], distance]", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 200.0, + "hit", + 50.0, // victimId + []interface{}{42.0, "PKP Pecheneg"}, // [shooterId, weapon] + 25.0, // distance + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.TargetID != 50 { + t.Errorf("TargetID = %d, want %d", evt.TargetID, 50) + } + if evt.SourceID != 42 { + t.Errorf("SourceID = %d, want %d", evt.SourceID, 42) + } + if evt.Weapon != "PKP Pecheneg" { + t.Errorf("Weapon = %q, want %q", evt.Weapon, "PKP Pecheneg") + } + }) + + t.Run("old extension killed event with only killerId in array", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 100.0, + "killed", + 10.0, + []interface{}{5.0}, // Only killerId, no weapon + 50.0, + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.SourceID != 5 { + t.Errorf("SourceID = %d, want %d", evt.SourceID, 5) + } + if evt.Weapon != "" { + t.Errorf("Weapon = %q, want empty string", evt.Weapon) + } + }) + + t.Run("alternative format killed event [frameNum, type, sourceId, targetId, weapon, distance]", func(t *testing.T) { + // Alternative format: [frameNum, "type", sourceId, targetId, weapon, distance] + evt := p.parseEvent([]interface{}{ + 8.0, // frameNum + "killed", // type + 0.0, // sourceId + 1.0, // targetId + "arifle_MX", // weapon + 150.0, // distance + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.SourceID != 0 { + t.Errorf("SourceID = %d, want %d", evt.SourceID, 0) + } + if evt.TargetID != 1 { + t.Errorf("TargetID = %d, want %d", evt.TargetID, 1) + } + if evt.Weapon != "arifle_MX" { + t.Errorf("Weapon = %q, want %q", evt.Weapon, "arifle_MX") + } + if evt.Distance != 150.0 { + t.Errorf("Distance = %v, want %v", evt.Distance, 150.0) + } + }) } func TestParserV1_parseMarker_EdgeCases(t *testing.T) { @@ -657,6 +892,62 @@ func TestParserV1_parseMarkerPosition_Formats(t *testing.T) { } }) + t.Run("old extension format [frameNum, [x, y], direction]", func(t *testing.T) { + // Old extension produces: [frameNum, [x, y], direction, ?alpha] + pos := p.parseMarkerPosition([]interface{}{ + 50.0, // frameNum + []interface{}{100.0, 200.0}, // [x, y] + 90.0, // direction + }) + if pos == nil { + t.Fatal("expected non-nil position") + } + if pos.FrameNum != 50 { + t.Errorf("FrameNum = %d, want %d", pos.FrameNum, 50) + } + if pos.PosX != 100.0 { + t.Errorf("PosX = %v, want %v", pos.PosX, 100.0) + } + if pos.PosY != 200.0 { + t.Errorf("PosY = %v, want %v", pos.PosY, 200.0) + } + if pos.Direction != 90.0 { + t.Errorf("Direction = %v, want %v", pos.Direction, 90.0) + } + }) + + t.Run("old extension format [frameNum, [x, y], direction, alpha]", func(t *testing.T) { + pos := p.parseMarkerPosition([]interface{}{ + 50.0, // frameNum + []interface{}{100.0, 200.0}, // [x, y] + 90.0, // direction + 75.0, // alpha + }) + if pos == nil { + t.Fatal("expected non-nil position") + } + if pos.FrameNum != 50 { + t.Errorf("FrameNum = %d, want %d", pos.FrameNum, 50) + } + if pos.Alpha != 75.0 { + t.Errorf("Alpha = %v, want %v", pos.Alpha, 75.0) + } + }) + + t.Run("old extension format [frameNum, [x, y, z], direction]", func(t *testing.T) { + pos := p.parseMarkerPosition([]interface{}{ + 50.0, // frameNum + []interface{}{100.0, 200.0, 10.0}, // [x, y, z] + 90.0, // direction + }) + if pos == nil { + t.Fatal("expected non-nil position") + } + if pos.PosZ != 10.0 { + t.Errorf("PosZ = %v, want %v", pos.PosZ, 10.0) + } + }) + t.Run("nil input", func(t *testing.T) { pos := p.parseMarkerPosition(nil) if pos != nil { From 83d0c494279b5b911ccdd8b22723bd590a4905cb Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 3 Feb 2026 00:21:21 +0100 Subject: [PATCH 3/3] test: increase coverage for parser_v1.go to 100% Add comprehensive edge case tests for: - Parse function: invalid entity/event/marker/time array types - parseEvent: non-combat events with message, float distance at index 4, events with only source, old extension events with empty/partial arrays - Entity with nil position data All parser_v1.go functions now have 100% statement coverage. --- internal/storage/parser_test.go | 227 ++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/internal/storage/parser_test.go b/internal/storage/parser_test.go index 6d2a5a1c..0cf1272c 100644 --- a/internal/storage/parser_test.go +++ b/internal/storage/parser_test.go @@ -145,6 +145,125 @@ func TestParserV1_Parse_MinimalData(t *testing.T) { } } +func TestParserV1_Parse_EdgeCases(t *testing.T) { + p := &ParserV1{} + + t.Run("invalid entity type in array (not a map)", func(t *testing.T) { + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 10.0, + "captureDelay": 1.0, + "entities": []interface{}{ + "not a map", // Invalid - should be skipped + []interface{}{"also not a map"}, // Invalid - should be skipped + map[string]interface{}{"id": 0.0, "type": "unit", "name": "Valid"}, // Valid + }, + } + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if len(result.Entities) != 1 { + t.Errorf("len(Entities) = %d, want %d (invalid entries skipped)", len(result.Entities), 1) + } + }) + + t.Run("invalid event type in array (not an array)", func(t *testing.T) { + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 10.0, + "captureDelay": 1.0, + "entities": []interface{}{}, + "events": []interface{}{ + "not an array", // Invalid - should be skipped + map[string]interface{}{"frame": 0.0}, // Invalid - should be skipped + []interface{}{0.0}, // Too short - should be skipped + []interface{}{0.0, "valid"}, // Valid + }, + } + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if len(result.Events) != 1 { + t.Errorf("len(Events) = %d, want %d (invalid entries skipped)", len(result.Events), 1) + } + }) + + t.Run("invalid marker type in array (not an array)", func(t *testing.T) { + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 10.0, + "captureDelay": 1.0, + "entities": []interface{}{}, + "Markers": []interface{}{ + "not an array", // Invalid - should be skipped + map[string]interface{}{"type": "ICON"}, // Invalid - should be skipped + []interface{}{"ICON", "text", 0.0, 10.0, 0.0, "color", 0.0}, // Valid + }, + } + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if len(result.Markers) != 1 { + t.Errorf("len(Markers) = %d, want %d (invalid entries skipped)", len(result.Markers), 1) + } + }) + + t.Run("invalid time entry type (not a map)", func(t *testing.T) { + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 10.0, + "captureDelay": 1.0, + "entities": []interface{}{}, + "times": []interface{}{ + "not a map", // Invalid - should be skipped + []interface{}{0.0, 1.0}, // Invalid - should be skipped + map[string]interface{}{"frameNum": 0.0, "time": 100.0}, // Valid + }, + } + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if len(result.Times) != 1 { + t.Errorf("len(Times) = %d, want %d (invalid entries skipped)", len(result.Times), 1) + } + }) + + t.Run("entity with nil position data", func(t *testing.T) { + data := map[string]interface{}{ + "worldName": "Altis", + "missionName": "Test", + "endFrame": 10.0, + "captureDelay": 1.0, + "entities": []interface{}{ + map[string]interface{}{ + "id": 0.0, + "type": "unit", + "name": "NoPositions", + // No positions key + }, + }, + } + result, err := p.Parse(data, 100) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if len(result.Entities) != 1 { + t.Errorf("len(Entities) = %d, want %d", len(result.Entities), 1) + } + if len(result.EntityPositions) != 0 { + t.Errorf("len(EntityPositions) = %d, want %d (nil positions)", len(result.EntityPositions), 0) + } + }) +} + func TestParserV1_Parse_Entities(t *testing.T) { p := &ParserV1{} data := map[string]interface{}{ @@ -827,6 +946,114 @@ func TestParserV1_parseEvent_EdgeCases(t *testing.T) { t.Errorf("Distance = %v, want %v", evt.Distance, 150.0) } }) + + t.Run("non-combat event with message at index 4", func(t *testing.T) { + // Non-killed/hit event with string at index 4 should set Message + evt := p.parseEvent([]interface{}{ + 100.0, + "chat", + 5.0, // sourceId + 10.0, // targetId + "Hello world", // message (not weapon since type is not killed/hit) + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.Message != "Hello world" { + t.Errorf("Message = %q, want %q", evt.Message, "Hello world") + } + if evt.Weapon != "" { + t.Errorf("Weapon = %q, want empty (not a combat event)", evt.Weapon) + } + }) + + t.Run("event with float distance at index 4", func(t *testing.T) { + // When index 4 is a float, it's treated as distance + evt := p.parseEvent([]interface{}{ + 100.0, + "explosion", + 5.0, // sourceId + 10.0, // targetId + 50.5, // distance as float + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.Distance != 50.5 { + t.Errorf("Distance = %v, want %v", evt.Distance, 50.5) + } + }) + + t.Run("event with only source (no target)", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 100.0, + "fired", + 5.0, // sourceId only + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.SourceID != 5 { + t.Errorf("SourceID = %d, want %d", evt.SourceID, 5) + } + if evt.TargetID != 0 { + t.Errorf("TargetID = %d, want %d (not set)", evt.TargetID, 0) + } + }) + + t.Run("old extension event with empty killer array", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 100.0, + "killed", + 10.0, + []interface{}{}, // Empty array - no killer info + 50.0, + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.TargetID != 10 { + t.Errorf("TargetID = %d, want %d", evt.TargetID, 10) + } + if evt.SourceID != 0 { + t.Errorf("SourceID = %d, want %d (empty array)", evt.SourceID, 0) + } + if evt.Distance != 50.0 { + t.Errorf("Distance = %v, want %v", evt.Distance, 50.0) + } + }) + + t.Run("old extension event without distance", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 100.0, + "killed", + 10.0, + []interface{}{5.0, "rifle"}, + // No distance + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.Distance != 0 { + t.Errorf("Distance = %v, want %v (not set)", evt.Distance, 0.0) + } + }) + + t.Run("old extension event with non-float distance", func(t *testing.T) { + evt := p.parseEvent([]interface{}{ + 100.0, + "killed", + 10.0, + []interface{}{5.0, "rifle"}, + "not a number", // Distance that's not a float + }) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.Distance != 0 { + t.Errorf("Distance = %v, want %v (invalid type)", evt.Distance, 0.0) + } + }) } func TestParserV1_parseMarker_EdgeCases(t *testing.T) {