From fbb625962c09d8f13aa91345f4a643a60763772a Mon Sep 17 00:00:00 2001 From: gezimbll Date: Wed, 11 Jun 2025 15:45:36 +0200 Subject: [PATCH] added tests on thresholds processing --- config/rsrparser_test.go | 33 +++++ engine/action_plan_test.go | 119 +++++++++++++++++ engine/filters_test.go | 98 ++++++++++++++ engine/thresholds_test.go | 75 +++++++++++ engine/units_counter_test.go | 172 ++++++++++++++++++++++++ general_tests/threshold_ees_it_test.go | 178 +++++++++++++++++++++++++ 6 files changed, 675 insertions(+) create mode 100644 general_tests/threshold_ees_it_test.go diff --git a/config/rsrparser_test.go b/config/rsrparser_test.go index 5626052ad..7e2cf1b9f 100644 --- a/config/rsrparser_test.go +++ b/config/rsrparser_test.go @@ -877,3 +877,36 @@ func TestRSRParsersClone(t *testing.T) { t.Errorf("Expected clone to not modify the cloned") } } +func TestRSRParsersValues(t *testing.T) { + ts := []struct { + name string + prsRules string + value string + parsedValue string + }{ + {name: "TestSearchAndReplaceNumber", prsRules: "~*req.Config.FilterIDs[0]:s/^\\*gt:.*(\\d+)$/${1}/", value: "*gt:~*req.*sum#1:9", parsedValue: "9"}, + {name: "TestSearchAndReplaceNumberNotMatch", prsRules: "~*req.Destination:s/^\\+41(\\d+)$/${1}/", value: "+415504", parsedValue: "5504"}, + {name: "TestSearchAndReplaceEmptyReplace", prsRules: "~*req.Destination:s/^\\+41(\\d+)$//", value: "+415504", parsedValue: ""}, + {name: "TestSearchAndReplaceEmptySearch", prsRules: "~*req.Account:s/^100/${1}/", value: "1001", parsedValue: ""}, + {name: "TestReplaceInMiddle", prsRules: "~*req:User-Agent:s/^(kamailio)_(\\w+)$/${1}-${2}/", value: "kamailio_agent", parsedValue: "kamailio-agent"}, + } + + for _, tt := range ts { + t.Run(tt.name, func(t *testing.T) { + parser, err := NewRSRParser(tt.prsRules) + if err != nil { + t.Fatal(err) + } + if err := parser.Compile(); err != nil { + t.Error(err) + } + val, err := parser.parseValue(tt.value) + if err != nil { + t.Fatal(err) + } + if val != tt.parsedValue { + t.Errorf("expected %s, received %s", tt.parsedValue, val) + } + }) + } +} diff --git a/engine/action_plan_test.go b/engine/action_plan_test.go index 569205f64..d5697daea 100644 --- a/engine/action_plan_test.go +++ b/engine/action_plan_test.go @@ -768,3 +768,122 @@ func TestCheckDefaultTiming(t *testing.T) { }) } } +func TestActionTimingGetNextStartTime2(t *testing.T) { + tests := []struct { + name string + actionTiming *ActionTiming + startTime time.Time + expectedTime time.Time + expectedError bool + }{ + { + name: "MonthlyEstimatedTiming", + actionTiming: &ActionTiming{ + Timing: &RateInterval{ + Timing: &RITiming{ + ID: utils.MetaMonthlyEstimated, + StartTime: "00:00:00", + Years: []int{2024}, + Months: []time.Month{1, 2, 3}, + MonthDays: []int{31}, + }, + }, + }, + startTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC), + expectedTime: time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC), + }, + { + name: "WeekDaysCron", + actionTiming: &ActionTiming{ + Timing: &RateInterval{ + Timing: &RITiming{ + StartTime: "09:00:00", + Years: []int{2024}, + Months: []time.Month{1, 2, 3}, + MonthDays: []int{1, 15}, + WeekDays: []time.Weekday{time.Monday, time.Wednesday, time.Friday}, + EndTime: "17:00:00", + }, + }, + }, + startTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + expectedTime: time.Date(2024, 1, 3, 9, 0, 0, 0, time.UTC), + }, + { + name: "YearTransition", + actionTiming: &ActionTiming{ + Timing: &RateInterval{ + Timing: &RITiming{ + StartTime: "00:00:00", + Years: []int{2024, 2025}, + Months: []time.Month{12, 1}, + MonthDays: []int{31, 1}, + }, + }, + }, + startTime: time.Date(2024, 12, 31, 23, 0, 0, 0, time.UTC), + expectedTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "LeapYear", + actionTiming: &ActionTiming{ + Timing: &RateInterval{ + Timing: &RITiming{ + StartTime: "00:00:00", + Years: []int{2024}, + Months: []time.Month{2}, + MonthDays: []int{29}, + }, + }, + }, + startTime: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC), + expectedTime: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC), + }, + { + name: "NilTiming", + actionTiming: &ActionTiming{ + Timing: nil, + }, + startTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + expectedTime: time.Time{}, + expectedError: true, + }, + { + name: "EmptyTiming", + actionTiming: &ActionTiming{ + Timing: &RateInterval{ + Timing: nil, + }, + }, + startTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + expectedTime: time.Time{}, + expectedError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + if tt.actionTiming != nil { + tt.actionTiming.ResetStartTimeCache() + } + + result := tt.actionTiming.GetNextStartTime(tt.startTime) + + if tt.expectedError { + if !result.IsZero() { + t.Errorf("Expected zero time for error case, got: %v", result) + } + return + } + if !result.Equal(tt.expectedTime) { + t.Errorf("GetNextStartTime(%v) = %v; want %v", + tt.startTime, result, tt.expectedTime) + } + cachedResult := tt.actionTiming.GetNextStartTime(tt.startTime) + if !cachedResult.Equal(result) { + t.Errorf("Cached result differs: got %v, want %v", + cachedResult, result) + } + }) + } +} diff --git a/engine/filters_test.go b/engine/filters_test.go index bebb8cd6f..e8adb3484 100644 --- a/engine/filters_test.go +++ b/engine/filters_test.go @@ -3451,3 +3451,101 @@ func TestFilterToSQLQueryValidations(t *testing.T) { }) } } + +func TestWeightFromDynamics2(t *testing.T) { + cfg := config.NewDefaultCGRConfig() + connMgr = NewConnManager(cfg, nil) + db, _ := NewInternalDB(nil, nil, true, nil, cfg.DataDbCfg().Items) + dm := NewDataManager(db, cfg.CacheCfg(), nil) + filterS := NewFilterS(cfg, connMgr, dm) + testCases := []struct { + name string + dynamicWeights []*utils.DynamicWeight + tenant string + event utils.DataProvider + expectedWeight float64 + expectedErr bool + }{ + { + name: "EmptyDynamicWeight", + dynamicWeights: []*utils.DynamicWeight{}, + tenant: "cgrates.org", + event: utils.MapStorage{}, + expectedWeight: 0.0, + expectedErr: false, + }, + { + name: "MatchingWeight", + dynamicWeights: []*utils.DynamicWeight{ + { + FilterIDs: []string{"*string:~*req.Account:1001"}, + Weight: 10.0, + }, + }, + tenant: "cgrates.org", + event: utils.MapStorage{ + "*req": utils.MapStorage{ + "Account": "1001", + }, + }, + expectedWeight: 10.0, + }, + { + name: "MultipleMatchingWeights", + dynamicWeights: []*utils.DynamicWeight{ + { + FilterIDs: []string{"*prefix:~*req.Destination:GER"}, + Weight: 10.0, + }, + { + FilterIDs: []string{"*string:~*opts.subsys:*sessions"}, + Weight: 5.0, + }, + }, + tenant: "cgrates.org", + event: utils.MapStorage{ + "*req": utils.MapStorage{ + "Destination": "GER_0055", + }, + "*opts": utils.MapStorage{ + "*subsys": "*sessions", + }, + }, + expectedWeight: 10.0, + }, + { + name: "NoMatchingWeightFilterErr", + dynamicWeights: []*utils.DynamicWeight{ + { + FilterIDs: []string{"FLTR1"}, + Weight: 10.0, + }, + }, + tenant: "cgrates.org", + event: utils.MapStorage{ + "*req": utils.MapStorage{ + "Account": "1001", + }, + }, + expectedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + weight, err := WeightFromDynamics(tc.dynamicWeights, filterS, tc.tenant, tc.event) + if tc.expectedErr { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if weight != tc.expectedWeight { + t.Errorf("Expected weight %v, got %v", tc.expectedWeight, weight) + } + } + }) + } +} diff --git a/engine/thresholds_test.go b/engine/thresholds_test.go index 3f406c701..0514666c2 100644 --- a/engine/thresholds_test.go +++ b/engine/thresholds_test.go @@ -23,6 +23,7 @@ import ( "log" "os" "reflect" + "slices" "sort" "strings" "testing" @@ -2163,3 +2164,77 @@ func TestThresholdsStoreThresholdCacheSetErr(t *testing.T) { utils.Logger.SetLogLevel(0) } + +func TestThresholdProcessEvent(t *testing.T) { + cfg := config.NewDefaultCGRConfig() + cfg.ThresholdSCfg().IndexedSelects = false + db, _ := NewInternalDB(nil, nil, true, nil, cfg.DataDbCfg().Items) + dm := NewDataManager(db, cfg.CacheCfg(), nil) + fS := NewFilterS(cfg, nil, dm) + ths := NewThresholdService(dm, cfg, fS, nil) + thps := []*ThresholdProfile{ + { + Tenant: "cgrates.org", + ID: "TH1", + FilterIDs: []string{"*string:~*req.Account:1001"}, + MinHits: 3, + MaxHits: 2, + }, + { + Tenant: "cgrates.org", + ID: "TH2", + FilterIDs: []string{"*string:~*req.Account:1002"}, + MinHits: 2, + MaxHits: 3, + }, { + Tenant: "cgrates.org", + ID: "TH3", + FilterIDs: []string{"*string:~*req.Account:1003"}, + MinHits: 1, + MaxHits: -1, + }, + } + for _, thP := range thps { + if err := ths.dm.SetThresholdProfile(thP, false); err != nil { + t.Error(err) + } + } + tts := []struct { + name string + runs int + cgrEvnt map[string]any + matchedthIDs []string + }{ + { + name: "MinHitsLargerThanMaxHits", + runs: 3, + cgrEvnt: map[string]any{ + utils.AccountField: "1001", + }, + }, + { + name: "MinHitsLargerThanMaxHits", + runs: 4, + cgrEvnt: map[string]any{ + utils.AccountField: "1002", + }, + }, + {}, + } + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + var thIDs []string + for range tt.runs { + var err error + thIDs, err = ths.processEvent("cgrates.org", &utils.CGREvent{Event: tt.cgrEvnt}) + if err != nil { + t.Error(err) + } + } + if !slices.Equal(thIDs, tt.matchedthIDs) { + t.Errorf("expected: %v, received: %v", tt.matchedthIDs, thIDs) + } + + }) + } +} diff --git a/engine/units_counter_test.go b/engine/units_counter_test.go index 5bfefaea1..9c8590306 100644 --- a/engine/units_counter_test.go +++ b/engine/units_counter_test.go @@ -978,3 +978,175 @@ func TestEngineUnitCounterString(t *testing.T) { t.Errorf("Expected JSON: %s, got: %s", want, got) } } + +func TestResetCounters(t *testing.T) { + tests := []struct { + name string + initialCounters UnitCounters + action *Action + expectedCounters UnitCounters + }{ + { + name: "ResetAlCountersNilAction", + initialCounters: UnitCounters{ + utils.MetaMonetary: []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 100.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON1")}}, + {Value: 200.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON2")}}, + }, + }, + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 50.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON1")}}, + }, + }, + }, + utils.MetaVoice: []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 150.0, Filter: &BalanceFilter{ID: utils.StringPointer("VOICE1")}}, + }, + }, + }, + }, + action: nil, + expectedCounters: UnitCounters{ + utils.MetaMonetary: []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 0.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON1")}}, + {Value: 0.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON2")}}, + }, + }, + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 0.0, Filter: &BalanceFilter{ID: utils.StringPointer("BAL_MON1")}}, + }, + }, + }, + utils.MetaVoice: []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 0.0, Filter: &BalanceFilter{ID: utils.StringPointer("VOICE1")}}, + }, + }, + }, + }, + }, + { + name: "ResetCountersMonetaryBalanceType", + initialCounters: UnitCounters{ + "*monetary": []*UnitCounter{ + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 100.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON1")}}, + {Value: 200.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON2")}}, + }, + }, + }, + "*data": []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 50.0, Filter: &BalanceFilter{ID: utils.StringPointer("MB_BAL")}}, + }, + }, + }, + }, + action: &Action{ + Balance: &BalanceFilter{Type: utils.StringPointer("*monetary")}, + }, + expectedCounters: UnitCounters{ + "*monetary": []*UnitCounter{ + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 100.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON1")}}, + {Value: 200.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON2")}}, + }, + }, + }, + "*data": []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 50.0, Filter: &BalanceFilter{ID: utils.StringPointer("MB_BAL")}}, + }, + }, + }, + }, + }, + { + name: "ResetSpecificBalanceType", + initialCounters: UnitCounters{ + "*monetary": []*UnitCounter{ + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 100.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON1"), Type: utils.StringPointer("*monetary")}}, + {Value: 200.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON2"), Type: utils.StringPointer("*monetary")}}, + }, + }, + }, + }, + action: &Action{ + Balance: &BalanceFilter{ID: utils.StringPointer("MON1"), Type: utils.StringPointer("*monetary")}, + }, + expectedCounters: UnitCounters{ + "*monetary": []*UnitCounter{ + { + CounterType: utils.MetaBalance, + Counters: CounterFilters{ + {Value: 0.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON1"), Type: utils.StringPointer("*monetary")}}, + {Value: 200.0, Filter: &BalanceFilter{ID: utils.StringPointer("MON2"), Type: utils.StringPointer("*monetary")}}, + }, + }, + }, + }, + }, + { + name: "ActionBalanceTypeNotExist", + initialCounters: UnitCounters{ + "*data": []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 150.0, Filter: &BalanceFilter{ID: utils.StringPointer("DATA1"), Type: utils.StringPointer("*data")}}, + }, + }, + }, + }, + action: &Action{ + Balance: &BalanceFilter{Type: utils.StringPointer("*monetary")}, + }, + expectedCounters: UnitCounters{ + "*data": []*UnitCounter{ + { + CounterType: utils.MetaCounterEvent, + Counters: CounterFilters{ + {Value: 150.0, Filter: &BalanceFilter{ID: utils.StringPointer("DATA1"), Type: utils.StringPointer("*data")}}, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cloneInitialCounters := tt.initialCounters.Clone() + cloneInitialCounters.resetCounters(tt.action) + if !reflect.DeepEqual(cloneInitialCounters, tt.expectedCounters) { + t.Errorf("mismatch after resetCounters.\nExpected:\n%s\nGot:\n%s", + utils.ToJSON(tt.expectedCounters), utils.ToJSON(cloneInitialCounters)) + } + }) + } +} diff --git a/general_tests/threshold_ees_it_test.go b/general_tests/threshold_ees_it_test.go new file mode 100644 index 000000000..6a89f2cbe --- /dev/null +++ b/general_tests/threshold_ees_it_test.go @@ -0,0 +1,178 @@ +//go:build integration +// +build integration + +/* +Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments +Copyright (C) ITsysCOM GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see +*/ +package general_tests + +import ( + "bytes" + "testing" + "time" + + "github.com/cgrates/birpc/context" + "github.com/cgrates/cgrates/engine" + "github.com/cgrates/cgrates/utils" +) + +func TestThresholdEventEEs(t *testing.T) { + var dbConfig engine.DBCfg + switch *utils.DBType { + case utils.MetaMySQL: + case utils.MetaMongo: + dbConfig = engine.MongoDBCfg + case utils.MetaPostgres, utils.MetaInternal: + t.SkipNow() + default: + t.Fatal("unsupported dbtype value") + } + content := `{ + "general": { + "log_level": 7, + }, + "apiers": { + "enabled": true + }, + "cdrs":{ + "enabled": true, + "stats_conns": ["*localhost"], + }, + "stats": { + "enabled": true, + "indexed_selects":false, + "thresholds_conns": ["*localhost"], + }, + "thresholds": { + "enabled": true, + "indexed_selects":false, + "ees_conns": ["*localhost"] + }, + "ees": { + "enabled": true, + "exporters": [ + { + "id": "exporter1", + "type": "*virt", + "attempts": 1, + "synchronous": true, + "fields":[ + {"tag": "Filter1", "path": "*uch.Filter1", "type": "*variable", "value": "~*req.Config.FilterIDs[0]"}, + {"tag": "Filter2", "path": "*uch.Filter2", "type": "*variable", "value": "~*req.Config.FilterIDs[1]"}, + ], + }, + ] +} + }` + + csvFiles := map[string]string{ + utils.StatsCsv: `#Tenant[0],Id[1],FilterIDs[2],ActivationInterval[3],QueueLength[4],TTL[5],MinItems[6],Metrics[7],MetricFilterIDs[8],Stored[9],Blocker[10],Weight[11],ThresholdIDs[12] +cgrates.org,SQ_1,*string:~*req.Account:1001,,,-1,,*sum#1,,false,,,TH1`, + utils.ThresholdsCsv: `#Tenant[0],Id[1],FilterIDs[2],ActivationInterval[3],MaxHits[4],MinHits[5],MinSleep[6],Blocker[7],Weight[8],ActionIDs[9],Async[10],EeIDs[11] +cgrates.org,TH1,*string:~*req.StatID:SQ_1;*eq:~*req.*sum#1:2,,-1,1,0,false,,ACT_LOG,false,exporter1`, + utils.ActionsCsv: `#ActionsId[0],Action[1],ExtraParameters[2],Filter[3],BalanceId[4],BalanceType[5],Categories[6],DestinationIds[7],RatingSubject[8],SharedGroup[9],ExpiryTime[10],TimingIds[11],Units[12],BalanceWeight[13],BalanceBlocker[14],BalanceDisabled[15],Weight[16] +ACT_LOG,*log,,,,,,,,,,,,,,,0`, + } + + ng := engine.TestEngine{ + ConfigJSON: content, + DBCfg: dbConfig, + LogBuffer: bytes.NewBuffer(nil), + TpFiles: csvFiles, + } + client, _ := ng.Run(t) + + t.Run("CDREventStatsToThreshold", func(t *testing.T) { + // event from StatS to ThresholdS returns NOT_FOUND but should be ignored + var reply string + if err := client.Call(context.Background(), + utils.CDRsV1ProcessEvent, + &engine.ArgV1ProcessEvent{ + CGREvent: utils.CGREvent{ + Tenant: "cgrates.org", + ID: "TestEv1", + Event: map[string]any{ + utils.ToR: utils.MetaVoice, + utils.OriginID: "Origin2", + utils.RequestType: utils.MetaPrepaid, + utils.AccountField: "1001", + utils.Subject: "1001", + utils.Destination: "1002", + utils.Usage: time.Minute, + }, + }, + }, &reply); err != nil { + t.Error("Unexpected error: ", err.Error()) + } else if reply != utils.OK { + t.Error("Unexpected reply received: ", reply) + } + + }) + + t.Run("ThresholdsToEEsEvent", func(t *testing.T) { + // it matches the threshold and passes the event to EEs without any errors + var reply string + if err := client.Call(context.Background(), + utils.CDRsV1ProcessEvent, + &engine.ArgV1ProcessEvent{ + CGREvent: utils.CGREvent{ + Tenant: "cgrates.org", + ID: "TestEv1", + Event: map[string]any{ + utils.ToR: utils.MetaVoice, + utils.OriginID: "Origin1", + utils.RequestType: utils.MetaPrepaid, + utils.AccountField: "1001", + utils.Subject: "1001", + utils.Destination: "1002", + utils.Usage: time.Minute, + }, + }, + }, &reply); err != nil { + t.Error("Unexpected error: ", err.Error()) + } else if reply != utils.OK { + t.Error("Unexpected reply received: ", reply) + } + }) + t.Run("CheckExporterIDs", func(t *testing.T) { + // filters in event always should be in ascending order + var filter1 any + if err := client.Call(context.Background(), utils.CacheSv1GetItem, &utils.ArgsGetCacheItemWithAPIOpts{ + Tenant: "cgrates.org", + ArgsGetCacheItem: utils.ArgsGetCacheItem{ + CacheID: utils.CacheUCH, + ItemID: "Filter1", + }, + }, &filter1); err != nil { + t.Error(err) + } else if filter1 != "*eq:~*req.*sum#1:2" { + t.Errorf("expected %v, received %v", "*eq:~*req.*sum#1:2", filter1) + } + var filter2 any + if err := client.Call(context.Background(), utils.CacheSv1GetItem, &utils.ArgsGetCacheItemWithAPIOpts{ + Tenant: "cgrates.org", + ArgsGetCacheItem: utils.ArgsGetCacheItem{ + CacheID: utils.CacheUCH, + ItemID: "Filter2", + }, + }, &filter2); err != nil { + t.Error(err) + } else if filter2 != "*string:~*req.StatID:SQ_1" { + t.Errorf("expected %v, received %v", "*string:~*req.StatID:SQ_1", filter2) + } + }) +}