fix: improve measurement recommend validation and offset handling

- use unified response codes for measurement recommend handler
- reject recommend inputs with continuous dots as invalid params
- fix exact and continuation match offsets for recommend results
- preserve correct offsets for fallback recommend scenarios
- use hash-based confirmation for measurement token7 recommendations
- write component and bay token7 hash indexes for HEXISTS validation
- add tests for input validation, hash keys, and recommend offset cases
This commit is contained in:
douxu 2026-07-09 16:31:19 +08:00
parent 180b0f7843
commit 6f78d8e341
8 changed files with 427 additions and 36 deletions

View File

@ -2,7 +2,8 @@
package handler package handler
import ( import (
"net/http" "errors"
"strings"
"modelRT/constants" "modelRT/constants"
"modelRT/logger" "modelRT/logger"
@ -49,9 +50,13 @@ func MeasurementRecommendHandler(c *gin.Context) {
if err := c.ShouldBindQuery(&request); err != nil { if err := c.ShouldBindQuery(&request); err != nil {
logger.Error(ctx, "failed to bind measurement recommend request", "error", err) logger.Error(ctx, "failed to bind measurement recommend request", "error", err)
c.JSON(http.StatusOK, network.FailureResponse{ renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
Code: http.StatusBadRequest, return
Msg: err.Error(), }
if err := validateMeasurementRecommendInput(request.Input); err != nil {
logger.Warn(ctx, "invalid measurement recommend input", "input", request.Input, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), map[string]any{
"input": request.Input,
}) })
return return
} }
@ -67,12 +72,8 @@ func MeasurementRecommendHandler(c *gin.Context) {
if recommendResult.Err != nil { if recommendResult.Err != nil {
err := recommendResult.Err err := recommendResult.Err
logger.Error(ctx, "failed to get recommend data from redis", "input", request.Input, "error", err) logger.Error(ctx, "failed to get recommend data from redis", "input", request.Input, "error", err)
c.JSON(http.StatusOK, network.FailureResponse{ renderRespFailure(c, constants.RespCodeServerError, err.Error(), map[string]any{
Code: http.StatusInternalServerError,
Msg: err.Error(),
Payload: map[string]any{
"input": request.Input, "input": request.Input,
},
}) })
return return
} }
@ -91,11 +92,7 @@ func MeasurementRecommendHandler(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, network.SuccessResponse{ renderRespSuccess(c, constants.RespCodeSuccess, "success", payload)
Code: http.StatusOK,
Msg: "success",
Payload: payload,
})
} }
func orderedRecommendResults(recommendResults map[string]model.SearchResult) []model.SearchResult { func orderedRecommendResults(recommendResults map[string]model.SearchResult) []model.SearchResult {
@ -119,3 +116,10 @@ func orderedRecommendResults(recommendResults map[string]model.SearchResult) []m
} }
return results return results
} }
func validateMeasurementRecommendInput(input string) error {
if strings.Contains(input, "..") {
return errors.New("input contains continuous dots")
}
return nil
}

View File

@ -0,0 +1,44 @@
package handler
import "testing"
func TestValidateMeasurementRecommendInput(t *testing.T) {
tests := []struct {
name string
input string
valid bool
}{
{
name: "continuous dots",
input: "G..",
valid: false,
},
{
name: "single separator",
input: "G.zone",
valid: true,
},
{
name: "trailing dot",
input: "G.",
valid: true,
},
{
name: "empty input",
input: "",
valid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateMeasurementRecommendInput(tt.input)
if tt.valid && err != nil {
t.Fatalf("expected valid input, got error %v", err)
}
if !tt.valid && err == nil {
t.Fatalf("expected invalid input")
}
})
}
}

View File

@ -30,6 +30,7 @@ func StoreComponentColumnRecommend(ctx context.Context, compTagToFullPath map[st
for compTag, fullPath := range compTagToFullPath { for compTag, fullPath := range compTagToFullPath {
specCompMeasKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag) specCompMeasKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag)
pipe.SAdd(ctx, specCompMeasKey, componentColumnMembers...) pipe.SAdd(ctx, specCompMeasKey, componentColumnMembers...)
pipe.HSet(ctx, componentColumnGroupKey(compTag), componentColumnHashFields(componentColumnNames)...)
isLocalFullPath := isLocalCompTagToFullPath[compTag] isLocalFullPath := isLocalCompTagToFullPath[compTag]
fullConfigTerm := fmt.Sprintf("%s.%s", fullPath, constants.ComponentConfigKey) fullConfigTerm := fmt.Sprintf("%s.%s", fullPath, constants.ComponentConfigKey)
@ -80,3 +81,11 @@ func stringSliceToAny(values []string) []any {
} }
return members return members
} }
func componentColumnGroupKey(compTag string) string {
return recommendGroupKey(compTag, constants.ComponentConfigKey)
}
func componentColumnHashFields(columnNames []string) []any {
return recommendHashFields(columnNames)
}

View File

@ -0,0 +1,22 @@
package model
import (
"reflect"
"testing"
)
func TestComponentColumnGroupKey(t *testing.T) {
got := componentColumnGroupKey("cable_26-demoProject110kV_TV")
want := "cable_26-demoProject110kV_TV_component"
if got != want {
t.Fatalf("expected key %q, got %q", want, got)
}
}
func TestComponentColumnHashFields(t *testing.T) {
got := componentColumnHashFields([]string{"global_uuid", "nspath"})
want := []any{"global_uuid", true, "nspath", true}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected fields %#v, got %#v", want, got)
}
}

View File

@ -184,6 +184,9 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement
sug = append(sug, redisearch.Suggestion{Term: measTerm, Score: constants.DefaultScore}) sug = append(sug, redisearch.Suggestion{Term: measTerm, Score: constants.DefaultScore})
} }
safeSAdd(fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag), measTags) safeSAdd(fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag), measTags)
if len(measTags) > 0 {
pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...)
}
ac.AddTerms(sug...) ac.AddTerms(sug...)
} }

15
model/recommend_hash.go Normal file
View File

@ -0,0 +1,15 @@
package model
import "fmt"
func recommendGroupKey(compTag string, configToken string) string {
return fmt.Sprintf("%s_%s", compTag, configToken)
}
func recommendHashFields(values []string) []any {
fields := make([]any, 0, len(values)*2)
for _, value := range values {
fields = append(fields, value, true)
}
return fields
}

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"slices"
"strings" "strings"
"sync" "sync"
@ -25,7 +26,7 @@ type SearchResult struct {
// input redis key, used to distinguish which goroutine the result belongs to // input redis key, used to distinguish which goroutine the result belongs to
RecommendType constants.RecommendHierarchyType RecommendType constants.RecommendHierarchyType
QueryDatas []string QueryDatas []string
IsFuzzy bool IsFuzzy bool // 代表本次搜索是否是模糊搜索, 例如输入 grid1.
IsFallback bool IsFallback bool
Offset int Offset int
Err error Err error
@ -277,20 +278,33 @@ func normalizeRecommendResults(input string, results map[string]SearchResult) ma
if result.Err != nil { if result.Err != nil {
continue continue
} }
if result.IsFallback { if !result.IsFuzzy {
result.Offset = maxRecommendLength(result.QueryDatas) if hasExactCompletion(result.QueryDatas) {
result.Offset = len([]rune(input))
} else if hasLevelContinuation(result.QueryDatas) {
result.Offset = len([]rune(input))
results[key] = result results[key] = result
continue continue
} } else {
if !result.IsFuzzy {
result.Offset = calcSearchResultOffset(input, result.QueryDatas) result.Offset = calcSearchResultOffset(input, result.QueryDatas)
} }
}
result.QueryDatas = trimRecommendTerms(result.QueryDatas, result.Offset) result.QueryDatas = trimRecommendTerms(result.QueryDatas, result.Offset)
results[key] = result results[key] = result
} }
return results return results
} }
// hasExactCompletion 判断推荐项是否表示当前输入已经完整命中, 无需再补充后缀。
func hasExactCompletion(recommends []string) bool {
return slices.Contains(recommends, "")
}
// hasLevelContinuation 判断推荐项是否表示当前层级已命中, 可继续补充分隔符进入下一层。
func hasLevelContinuation(recommends []string) bool {
return slices.Contains(recommends, ".")
}
// calcSearchResultOffset 计算 input 与推荐项的最短公共前缀长度, 用作裁剪位置。 // calcSearchResultOffset 计算 input 与推荐项的最短公共前缀长度, 用作裁剪位置。
func calcSearchResultOffset(input string, recommends []string) int { func calcSearchResultOffset(input string, recommends []string) int {
var minOffset int var minOffset int
@ -303,15 +317,13 @@ func calcSearchResultOffset(input string, recommends []string) int {
return minOffset return minOffset
} }
// maxRecommendLength 返回推荐项最大长度, fallback 结果用它表示无需裁剪已有输入。 // recommendPrefixOffset 返回推荐结果中已由输入确定的前缀长度, 包含末尾分隔符。
func maxRecommendLength(recommends []string) int { func recommendPrefixOffset(prefixTokens []string) int {
var maxLen int prefix := strings.Join(prefixTokens, ".")
for _, recommend := range recommends { if prefix == "" {
if length := len([]rune(recommend)); length > maxLen { return 0
maxLen = length
} }
} return len([]rune(prefix)) + 1
return maxLen
} }
// trimRecommendTerms 按 offset 截掉已输入前缀, 并去掉空值和重复值。 // trimRecommendTerms 按 offset 截掉已输入前缀, 并去掉空值和重复值。
@ -552,6 +564,45 @@ func combineQueryResultByInput(hierarchy constants.RecommendHierarchyType, recom
return recommandResults return recommandResults
} }
// confirmMeasRecommendResultsByHash 用 token5-token6 hash 确认 token7 是否属于当前配置组。
func confirmMeasRecommendResultsByHash(ctx context.Context, rdb *redis.Client, inputSlice []string, members []string, recommends []string) []string {
compTag, configToken, ok := recommendGroupTokens(inputSlice)
if !ok {
return []string{}
}
hashKey := recommendGroupKey(compTag, configToken)
confirmedResults := make([]string, 0, len(recommends))
for index, recommend := range recommends {
if index >= len(members) {
break
}
exists, err := rdb.HExists(ctx, hashKey, members[index]).Result()
if err != nil {
logger.Error(ctx, "measurement recommend hash confirmation failed", "hash_key", hashKey, "field", members[index], "error", err)
continue
}
if !exists {
continue
}
confirmedResults = append(confirmedResults, recommend)
}
return confirmedResults
}
func recommendGroupTokens(inputSlice []string) (string, string, bool) {
searchInputIndex := len(inputSlice) - 1
compTagIndex := searchInputIndex - 2
configTokenIndex := searchInputIndex - 1
if compTagIndex < 0 || configTokenIndex < 0 || configTokenIndex >= len(inputSlice) {
return "", "", false
}
compTag := inputSlice[compTagIndex]
configToken := inputSlice[configTokenIndex]
return compTag, configToken, compTag != "" && configToken != ""
}
// getSpecificKeyByLength 根据层级和父级 token 生成对应的 specific set key。 // getSpecificKeyByLength 根据层级和父级 token 生成对应的 specific set key。
func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefix string) string { func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefix string) string {
switch hierarchy { switch hierarchy {
@ -648,11 +699,30 @@ func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice [
Err: err, Err: err,
} }
} }
isFallback := false
if len(recommends) == 0 {
queryKey := fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, nsPath)
members, err := rdb.SMembers(ctx, queryKey).Result()
if err != nil && err != redis.Nil {
logger.Error(ctx, "fallback measurement tags by nspath failed", "key", queryKey, "nspath", nsPath, "error", err)
return SearchResult{
RecommendType: hierarchy,
QueryDatas: nil,
IsFuzzy: true,
IsFallback: true,
Err: err,
}
}
recommends = combineNSPathMeasResults(nsPath, members)
offset = recommendPrefixOffset([]string{nsPath})
isFallback = true
}
return SearchResult{ return SearchResult{
RecommendType: hierarchy, RecommendType: hierarchy,
QueryDatas: recommends, QueryDatas: recommends,
IsFuzzy: true, IsFuzzy: true,
IsFallback: isFallback,
Offset: offset, Offset: offset,
Err: nil, Err: nil,
} }
@ -770,7 +840,10 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
} }
recommandResults := combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members) recommandResults := combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members)
if hierarchy == constants.ConfigRecommendHierarchyType || hierarchy == constants.MeasTagRecommendHierarchyType { if hierarchy == constants.MeasTagRecommendHierarchyType {
recommandResults = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults)
}
if hierarchy == constants.ConfigRecommendHierarchyType {
// check the relevance between the config hierarchy and measurement hierarchy output and the request input, i.e., use FT.SUGGET search_suggestions_dict "recommandResult" max 1 for an exact query to check if the result exists // check the relevance between the config hierarchy and measurement hierarchy output and the request input, i.e., use FT.SUGGET search_suggestions_dict "recommandResult" max 1 for an exact query to check if the result exists
secondConfirmResults := make([]string, 0, len(recommandResults)) secondConfirmResults := make([]string, 0, len(recommandResults))
for _, res := range recommandResults { for _, res := range recommandResults {
@ -854,8 +927,13 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
} }
} }
isFallback = true isFallback = true
if _, ok := fallbackSpecificSetKey(hierarchy, inputSlice); ok {
recommends = combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, recommends)
offset = recommendPrefixOffset(inputSlice[:len(inputSlice)-1])
} else {
offset = 0 offset = 0
} }
}
return SearchResult{ return SearchResult{
RecommendType: hierarchy, RecommendType: hierarchy,
@ -871,17 +949,18 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) { func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) {
var configToken string var configToken string
var comparePrefix string var comparePrefix string
var memberComparePrefix string
searchInputLen := len([]rune(searchInput)) searchInputLen := len([]rune(searchInput))
compareHierarchyLen := int(hierarchy) compareHierarchyLen := int(hierarchy)
comparePrefix = searchPrefix comparePrefix = searchPrefix
memberComparePrefix = searchPrefix
if hierarchy == constants.MeasTagRecommendHierarchyType { if hierarchy == constants.MeasTagRecommendHierarchyType {
compareHierarchyLen = int(hierarchy) - 1
lastDotIndex := strings.LastIndex(searchPrefix, ".") lastDotIndex := strings.LastIndex(searchPrefix, ".")
if lastDotIndex == -1 { if lastDotIndex == -1 {
configToken = "" configToken = ""
} else { } else {
configToken = searchPrefix[lastDotIndex+1:] configToken = searchPrefix[lastDotIndex+1:]
comparePrefix = searchPrefix[:lastDotIndex] memberComparePrefix = searchPrefix[:lastDotIndex]
} }
} }
@ -941,7 +1020,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
} }
if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) { if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) {
exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, comparePrefix, termLastPart) exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, memberComparePrefix, termLastPart)
if err != nil || !exists { if err != nil || !exists {
logger.Info(ctx, "fuzzy recommend term not found in specific redis set", logger.Info(ctx, "fuzzy recommend term not found in specific redis set",
"hierarchy", hierarchy, "hierarchy", hierarchy,
@ -956,7 +1035,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
} }
recommend := result.Term recommend := result.Term
if hierarchy == constants.MeasTagRecommendHierarchyType { if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix {
recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".")
} }
recommends = append(recommends, recommend) recommends = append(recommends, recommend)

View File

@ -2,6 +2,7 @@ package model
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
"modelRT/constants" "modelRT/constants"
@ -44,6 +45,220 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) {
} }
} }
func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "full token1 to token7 structure",
input: "grid000.zone000.station000.220kV_学府路1-testProject1.compTag.config.IA_rms_CTA-testProject1",
},
{
name: "local token4 to token7 structure",
input: "220kV_学府路1-testProject1.IA_rms_CTA-testProject1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: []string{""},
IsFuzzy: false,
},
}
got := normalizeRecommendResults(tt.input, results)
result := got[constants.MeasTagRecommendHierarchyType.String()]
wantOffset := len([]rune(tt.input))
if result.Offset != wantOffset {
t.Fatalf("expected offset %d, got %d", wantOffset, result.Offset)
}
if len(result.QueryDatas) != 0 {
t.Fatalf("expected exact completion to return no suffix, got %v", result.QueryDatas)
}
})
}
}
func TestNormalizeRecommendResultsUsesInputLengthForLevelContinuation(t *testing.T) {
tests := []struct {
name string
input string
recommendType constants.RecommendHierarchyType
}{
{
name: "token1 grid can continue",
input: "grid000",
recommendType: constants.GridRecommendHierarchyType,
},
{
name: "token1 token2 zone can continue",
input: "grid000.zone000",
recommendType: constants.ZoneRecommendHierarchyType,
},
{
name: "token1 token2 token3 station can continue",
input: "grid000.zone000.station000",
recommendType: constants.StationRecommendHierarchyType,
},
{
name: "token1 token2 token3 token4 nspath can continue",
input: "grid000.zone000.station000.110kV_TV-demoProject",
recommendType: constants.CompNSPathRecommendHierarchyType,
},
{
name: "token1 token2 token3 token4 token5 compTag can continue",
input: "grid000.zone000.station000.110kV_TV-demoProject.cable_26-demoProject110kV_TV",
recommendType: constants.CompTagRecommendHierarchyType,
},
{
name: "token1 through token6 config can continue",
input: "grid000.zone000.station000.110kV_TV-demoProject.cable_26-demoProject110kV_TV.base_extend",
recommendType: constants.ConfigRecommendHierarchyType,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := map[string]SearchResult{
tt.recommendType.String(): {
RecommendType: tt.recommendType,
QueryDatas: []string{"."},
IsFuzzy: false,
},
}
got := normalizeRecommendResults(tt.input, results)
result := got[tt.recommendType.String()]
wantOffset := len([]rune(tt.input))
if result.Offset != wantOffset {
t.Fatalf("expected offset %d, got %d", wantOffset, result.Offset)
}
if len(result.QueryDatas) != 1 || result.QueryDatas[0] != "." {
t.Fatalf("expected level continuation '.', got %v", result.QueryDatas)
}
})
}
}
func TestNormalizeRecommendResultsKeepsFallbackOffsetZero(t *testing.T) {
input := "x"
results := map[string]SearchResult{
constants.GridRecommendHierarchyType.String(): {
RecommendType: constants.GridRecommendHierarchyType,
QueryDatas: []string{"grid000", "grid001"},
IsFuzzy: true,
IsFallback: true,
Offset: 0,
},
constants.CompNSPathRecommendHierarchyType.String(): {
RecommendType: constants.CompNSPathRecommendHierarchyType,
QueryDatas: []string{"nspath000", "nspath001"},
IsFuzzy: true,
IsFallback: true,
Offset: 0,
},
}
got := normalizeRecommendResults(input, results)
for key, result := range got {
if result.Offset != 0 {
t.Fatalf("expected fallback offset 0 for %s, got %d", key, result.Offset)
}
if len(result.QueryDatas) != 2 {
t.Fatalf("expected fallback recommends to remain intact for %s, got %v", key, result.QueryDatas)
}
}
}
func TestNormalizeRecommendResultsKeepsParentPrefixOffsetForSpecificFallback(t *testing.T) {
nsPath := "220kV_学府路1-testProject1"
input := nsPath + ".x"
offset := recommendPrefixOffset([]string{nsPath})
results := map[string]SearchResult{
constants.CompTagRecommendHierarchyType.String(): {
RecommendType: constants.CompTagRecommendHierarchyType,
QueryDatas: []string{
nsPath + ".CTA-testProject1220kV_学府路1",
nsPath + ".CB-testProject1220kV_学府路1",
},
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: []string{
nsPath + ".IA_rms_CTA-testProject1",
nsPath + ".IB_rms_CTA-testProject1",
},
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
}
got := normalizeRecommendResults(input, results)
if offset != 24 {
t.Fatalf("expected sample parent prefix offset 24, got %d", offset)
}
for key, result := range got {
if result.Offset != 24 {
t.Fatalf("expected specific fallback offset 24 for %s, got %d", key, result.Offset)
}
for _, recommend := range result.QueryDatas {
if strings.HasPrefix(recommend, nsPath+".") {
t.Fatalf("expected trimmed fallback recommend for %s, got %s", key, recommend)
}
}
}
}
func TestRecommendGroupTokens(t *testing.T) {
tests := []struct {
name string
inputSlice []string
wantCompTag string
wantConfig string
wantOK bool
}{
{
name: "full token1 to token7 input",
inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "base_extend", ""},
wantCompTag: "comp_tag",
wantConfig: "base_extend",
wantOK: true,
},
{
name: "local token4 to token7 input",
inputSlice: []string{"nspath", "comp_tag", "component", ""},
wantCompTag: "comp_tag",
wantConfig: "component",
wantOK: true,
},
{
name: "missing config token",
inputSlice: []string{"nspath", ""},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCompTag, gotConfig, gotOK := recommendGroupTokens(tt.inputSlice)
if gotOK != tt.wantOK {
t.Fatalf("expected ok %t, got %t", tt.wantOK, gotOK)
}
if gotCompTag != tt.wantCompTag || gotConfig != tt.wantConfig {
t.Fatalf("expected compTag/config %q/%q, got %q/%q", tt.wantCompTag, tt.wantConfig, gotCompTag, gotConfig)
}
})
}
}
func TestFallbackSpecificSetKey(t *testing.T) { func TestFallbackSpecificSetKey(t *testing.T) {
tests := []struct { tests := []struct {
name string name string