diff --git a/model/measurement_group_recommend_model.go b/model/measurement_group_recommend_model.go index 7985174..a97edc6 100644 --- a/model/measurement_group_recommend_model.go +++ b/model/measurement_group_recommend_model.go @@ -22,6 +22,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement compTagToFullPath := make(map[string]string) isLocalCompTagToFullPath := make(map[string]string) + var allErrs []error zoneToGridPath := make(map[string]string) for gridTag, zoneTags := range measSet.GridToZoneTags { @@ -62,12 +63,21 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement pipe.SAdd(ctx, key, members) } } + safeAddTerms := func(sug []redisearch.Suggestion) { + if len(sug) == 0 { + return + } + if err := ac.AddTerms(sug...); err != nil { + logger.Error(ctx, "add measurement group recommend suggestions failed", "error", err) + allErrs = append(allErrs, err) + } + } safeSAdd(constants.RedisAllGridSetKey, measSet.AllGridTags) gridSug := util.MapSlice(measSet.AllGridTags, func(gridTag string) redisearch.Suggestion { return redisearch.Suggestion{Term: gridTag, Score: constants.DefaultScore} }) - ac.AddTerms(gridSug...) + safeAddTerms(gridSug) safeSAdd(constants.RedisAllZoneSetKey, measSet.AllZoneTags) safeSAdd(constants.RedisAllStationSetKey, measSet.AllStationTags) @@ -83,7 +93,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement return redisearch.Suggestion{Term: fmt.Sprintf("%s.%s", gridTag, zoneTag), Score: constants.DefaultScore} }) safeSAdd(fmt.Sprintf(constants.RedisSpecGridZoneSetKey, gridTag), zoneTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the zone -> stations hierarchy @@ -101,7 +111,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement }) safeSAdd(fmt.Sprintf(constants.RedisSpecZoneStationSetKey, zoneTag), stationTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the station -> component nspaths hierarchy @@ -122,7 +132,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement sug = append(sug, redisearch.Suggestion{Term: nsPath, Score: constants.DefaultScore}) } safeSAdd(fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, stationTag), compNSPaths) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component nspath -> component tags hierarchy @@ -136,20 +146,17 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement } for _, compTag := range compTags { - fullPath := fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag) - compTagToFullPath[compTag] = fullPath - fullPath = fmt.Sprintf("%s.%s", compNSPath, compTag) - isLocalCompTagToFullPath[compTag] = fullPath + fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag) + compTagToFullPath[compTag] = fullTerm + isLocalCompTagToFullPath[compTag] = localTerm // add redis fuzzy search suggestion for token1-token7 type - term := fullPath - sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore}) + sug = append(sug, redisearch.Suggestion{Term: fullTerm, Score: constants.DefaultScore}) // add redis fuzzy search suggestion for token4-token7 type - term = fmt.Sprintf("%s.%s", compNSPath, compTag) - sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore}) + sug = append(sug, redisearch.Suggestion{Term: localTerm, Score: constants.DefaultScore}) } safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, compNSPath), compTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component tag -> measurement tags hierarchy @@ -187,7 +194,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement if len(measTags) > 0 { pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...) } - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component nspath -> measurement tags hierarchy for token4-token7 shorthand @@ -200,10 +207,9 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement }) } safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, compNSPath), measTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } - var allErrs []error cmders, execErr := pipe.Exec(ctx) if execErr != nil { logger.Error(ctx, "pipeline execution failed", "error", execErr) @@ -227,3 +233,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement return compTagToFullPath, isLocalCompTagToFullPath, nil } + +func compTagSuggestionTerms(parentPath string, compNSPath string, compTag string) (string, string) { + return fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag), fmt.Sprintf("%s.%s", compNSPath, compTag) +} diff --git a/model/measurement_group_recommend_model_test.go b/model/measurement_group_recommend_model_test.go new file mode 100644 index 0000000..3ff4713 --- /dev/null +++ b/model/measurement_group_recommend_model_test.go @@ -0,0 +1,21 @@ +package model + +import "testing" + +func TestCompTagSuggestionTermsKeepFullAndLocalPaths(t *testing.T) { + parentPath := "grid000.zone000.station000" + compNSPath := "110kV_TV-demoProject" + compTag := "cable_22-testProject1110kV_TV" + + fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag) + + wantFullTerm := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV" + if fullTerm != wantFullTerm { + t.Fatalf("expected full suggestion term %q, got %q", wantFullTerm, fullTerm) + } + + wantLocalTerm := "110kV_TV-demoProject.cable_22-testProject1110kV_TV" + if localTerm != wantLocalTerm { + t.Fatalf("expected local suggestion term %q, got %q", wantLocalTerm, localTerm) + } +} diff --git a/model/redis_recommend.go b/model/redis_recommend.go index c42c6ca..7483207 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -171,9 +171,9 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return finalizeResults("", results) } - inputSlice := strings.Split(input, ".") + searchInput := normalizeTrailingDotSearchInput(ctx, rdb, input) + inputSlice := strings.Split(searchInput, ".") inputSliceLen := len(inputSlice) - switch inputSliceLen { case 1: searchInput := inputSlice[0] @@ -185,7 +185,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 2: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -198,7 +198,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleNSPathMeasSearch(ctx, rdb, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 3: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -208,7 +208,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 4: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -218,28 +218,28 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 5: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 6: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 7: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) default: logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) return nil @@ -250,6 +250,92 @@ func shouldFallbackToInitialRecommend(input string) bool { return strings.HasPrefix(input, ".") } +// normalizeTrailingDotSearchInput 忽略误输入的尾随点号, 让前一段 token 继续参与模糊搜索。 +func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, input string) string { + if input == "" || !strings.HasSuffix(input, ".") || strings.HasSuffix(input, "..") { + return input + } + + trimmedInput := strings.TrimSuffix(input, ".") + if trimmedInput == "" { + return input + } + + checks := exactMatchChecksForInput(strings.Split(trimmedInput, ".")) + if len(checks) == 0 { + return input + } + + for _, check := range checks { + exists, err := rdb.SIsMember(ctx, check.setKey, check.member).Result() + if err != nil { + logger.Warn(ctx, "check trailing dot input exact match failed", "input", input, "key", check.setKey, "member", check.member, "error", err) + return input + } + if exists { + return input + } + } + return trimmedInput +} + +type exactMatchCheck struct { + setKey string + member string +} + +func exactMatchChecksForInput(inputSlice []string) []exactMatchCheck { + inputSliceLen := len(inputSlice) + if inputSliceLen == 0 { + return nil + } + + lastToken := inputSlice[inputSliceLen-1] + if lastToken == "" { + return nil + } + + switch inputSliceLen { + case 1: + return []exactMatchCheck{ + {setKey: constants.RedisAllGridSetKey, member: lastToken}, + {setKey: constants.RedisAllCompNSPathSetKey, member: lastToken}, + } + case 2: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, inputSlice[0]), member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[0]), member: lastToken}, + {setKey: constants.RedisAllCompTagSetKey, member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, inputSlice[0]), member: lastToken}, + } + case 3: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, inputSlice[1]), member: lastToken}, + {setKey: constants.RedisAllConfigSetKey, member: lastToken}, + } + case 4: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, inputSlice[2]), member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[1]), member: lastToken}, + } + case 5: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[3]), member: lastToken}, + {setKey: constants.RedisAllCompTagSetKey, member: lastToken}, + } + case 6: + return []exactMatchCheck{ + {setKey: constants.RedisAllConfigSetKey, member: lastToken}, + } + case 7: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[4]), member: lastToken}, + } + default: + return nil + } +} + // suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。 func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { hasConcreteHit := false @@ -396,30 +482,101 @@ func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy c } // fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。 -func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, inputSlice []string) ([]string, error) { +func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, inputSlice []string) ([]string, int, error) { allSetKey, ok := getAllSetKeyByHierarchy(hierarchy) if !ok { - return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + return nil, 0, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + } + + if hierarchy == constants.ConfigRecommendHierarchyType { + return fallbackConfigByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice) + } + if hierarchy == constants.MeasTagRecommendHierarchyType { + return fallbackMeasByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice) } specificSetKey, ok := fallbackSpecificSetKey(hierarchy, inputSlice) if !ok { - return fallbackAllCurrentLevel(ctx, rdb, hierarchy) + members, err := fallbackAllCurrentLevel(ctx, rdb, hierarchy) + return members, 0, err } count, err := rdb.SInterCard(ctx, 1, allSetKey, specificSetKey).Result() if err != nil && err != redis.Nil { - return nil, err + return nil, 0, err } if count == 0 { - return []string{}, nil + return []string{}, 0, nil } members, err := rdb.SMembers(ctx, specificSetKey).Result() if err != nil && err != redis.Nil { - return nil, err + return nil, 0, err } - return members, nil + return combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members), recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + +func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { + if len(inputSlice) < 2 { + members, err := fallbackAllCurrentLevel(ctx, rdb, constants.ConfigRecommendHierarchyType) + return members, 0, err + } + + members, err := rdb.SMembers(ctx, allSetKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + searchInput := inputSlice[len(inputSlice)-1] + matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) + if len(matchedMembers) == 0 { + return []string{}, 0, nil + } + + prefixTokens := inputSlice[:len(inputSlice)-1] + recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil +} + +func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { + members, err := rdb.SMembers(ctx, allSetKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + searchInput := inputSlice[len(inputSlice)-1] + matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) + if len(matchedMembers) == 0 { + return []string{}, 0, nil + } + + recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil +} + +func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) { + for len([]rune(searchInput)) != 0 { + filtered := make([]string, 0, len(members)) + for _, member := range members { + if strings.HasPrefix(member, searchInput) { + filtered = append(filtered, member) + } + } + if len(filtered) > 0 { + return filtered, searchInput + } + searchInput = trimLastRune(searchInput) + } + return []string{}, "" } // fallbackSpecificSetKey 根据输入切片推导 fallback 应该校验的上一层级 specific set。 @@ -591,6 +748,42 @@ func confirmMeasRecommendResultsByHash(ctx context.Context, rdb *redis.Client, i return confirmedResults } +func confirmConfigRecommendResultsByHash(ctx context.Context, rdb *redis.Client, inputSlice []string, members []string, recommends []string) []string { + compTag, ok := configRecommendCompTag(inputSlice) + if !ok { + return []string{} + } + + confirmedResults := make([]string, 0, len(recommends)) + for index, recommend := range recommends { + if index >= len(members) { + break + } + + hashKey := recommendGroupKey(compTag, members[index]) + exists, err := rdb.Exists(ctx, hashKey).Result() + if err != nil { + logger.Error(ctx, "config recommend hash confirmation failed", "hash_key", hashKey, "error", err) + continue + } + if exists == 0 { + continue + } + confirmedResults = append(confirmedResults, recommend) + } + return confirmedResults +} + +func configRecommendCompTag(inputSlice []string) (string, bool) { + configTokenIndex := len(inputSlice) - 1 + compTagIndex := configTokenIndex - 1 + if compTagIndex < 0 || compTagIndex >= len(inputSlice) { + return "", false + } + compTag := inputSlice[compTagIndex] + return compTag, compTag != "" +} + func recommendGroupTokens(inputSlice []string) (string, string, bool) { searchInputIndex := len(inputSlice) - 1 compTagIndex := searchInputIndex - 2 @@ -844,26 +1037,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co 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 - secondConfirmResults := make([]string, 0, len(recommandResults)) - for _, res := range recommandResults { - results, err := ac.SuggestOpts(res, redisearch.SuggestOptions{ - Num: 1, - Fuzzy: false, - WithScores: false, - WithPayloads: false, - }) - if err != nil { - logger.Error(ctx, "config hierarchy query key second confirmation failed", "query_key", res, "error", err) - continue - } - - if len(results) == 0 { - continue - } - secondConfirmResults = append(secondConfirmResults, res) - } - recommandResults = secondConfirmResults + recommandResults = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) } return SearchResult{ RecommendType: hierarchy, @@ -915,7 +1089,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co isFallback := false if len(recommends) == 0 { logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - recommends, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, inputSlice) + recommends, offset, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, recommendLenType, inputSlice) if err != nil { logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) return SearchResult{ @@ -927,12 +1101,6 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } isFallback = true - if _, ok := fallbackSpecificSetKey(hierarchy, inputSlice); ok { - recommends = combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, recommends) - offset = recommendPrefixOffset(inputSlice[:len(inputSlice)-1]) - } else { - offset = 0 - } } return SearchResult{ @@ -979,7 +1147,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, logger.Error(ctx, "query key by redis fuzzy search failed", "query_key", fuzzyInput, "error", err) return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) } - if len(results) == 0 { // 如果没有结果,退一步(删除最后一个字符)并继续循环 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 @@ -1041,7 +1208,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, recommends = append(recommends, recommend) } } - if len(recommends) == 0 { searchInput = trimLastRune(searchInput) searchInputLen = len([]rune(searchInput)) diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index 88721d5..a522789 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "reflect" "strings" "testing" @@ -217,6 +218,138 @@ func TestNormalizeRecommendResultsKeepsParentPrefixOffsetForSpecificFallback(t * } } +func TestNormalizeRecommendResultsTrimsConfigFuzzySuffixes(t *testing.T) { + input := "110kV_TV-testProject1.cable_22-testProject1110kV_TV.base_extend.c" + offset := len([]rune(input)) + results := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: []string{ + input + "apacity", + input + "ategory", + input + "urrent", + input + "ode", + }, + IsFuzzy: true, + Offset: offset, + }, + } + + got := normalizeRecommendResults(input, results) + result := got[constants.MeasTagRecommendHierarchyType.String()] + want := []string{"apacity", "ategory", "urrent", "ode"} + if result.Offset != offset { + t.Fatalf("expected offset %d, got %d", offset, result.Offset) + } + if !reflect.DeepEqual(result.QueryDatas, want) { + t.Fatalf("expected trimmed recommends %#v, got %#v", want, result.QueryDatas) + } +} + +func TestNormalizeRecommendResultsTrimsToken4FallbackCandidates(t *testing.T) { + nsPath := "110kV_TV-testProject1" + input := nsPath + ".x" + offset := recommendPrefixOffset([]string{nsPath}) + results := map[string]SearchResult{ + constants.CompTagRecommendHierarchyType.String(): { + RecommendType: constants.CompTagRecommendHierarchyType, + QueryDatas: []string{ + nsPath + ".cable_22-testProject1110kV_TV", + nsPath + ".cable_23-testProject1110kV_TV", + }, + 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) + for key, result := range got { + if result.Offset != offset { + t.Fatalf("expected offset %d for %s, got %d", offset, key, result.Offset) + } + for _, recommend := range result.QueryDatas { + if strings.HasPrefix(recommend, nsPath+".") { + t.Fatalf("expected trimmed token4 fallback recommend for %s, got %s", key, recommend) + } + } + } + if got[constants.CompTagRecommendHierarchyType.String()].QueryDatas[0] != "cable_22-testProject1110kV_TV" { + t.Fatalf("expected token5 suffixes, got %v", got[constants.CompTagRecommendHierarchyType.String()].QueryDatas) + } + if got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas[0] != "IA_rms_CTA-testProject1" { + t.Fatalf("expected token7 suffixes, got %v", got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas) + } +} + +func TestNormalizeRecommendResultsKeepsFullAndLocalConfigMeasurementSuffixesConsistent(t *testing.T) { + fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend" + localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend" + members := []string{"capacity", "category", "current"} + + fullResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members), + IsFuzzy: false, + }, + } + localResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members), + IsFuzzy: false, + }, + } + + fullGot := normalizeRecommendResults(fullPrefix+".", fullResults) + localGot := normalizeRecommendResults(localPrefix+".", localResults) + fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + if !reflect.DeepEqual(fullRecommends, localRecommends) { + t.Fatalf("expected full/local config measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends) + } +} + +func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsistent(t *testing.T) { + fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + members := []string{"IA_rms", "IB_rms", "IC_rms"} + + fullResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members), + IsFuzzy: false, + }, + } + localResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members), + IsFuzzy: false, + }, + } + + fullGot := normalizeRecommendResults(fullPrefix+".", fullResults) + localGot := normalizeRecommendResults(localPrefix+".", localResults) + fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + if !reflect.DeepEqual(fullRecommends, localRecommends) { + t.Fatalf("expected full/local bay measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends) + } +} + func TestRecommendGroupTokens(t *testing.T) { tests := []struct { name string @@ -259,6 +392,242 @@ func TestRecommendGroupTokens(t *testing.T) { } } +func TestConfigRecommendCompTag(t *testing.T) { + tests := []struct { + name string + inputSlice []string + want string + wantOK bool + }{ + { + name: "full token1 to token6 input", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"}, + want: "comp_tag", + wantOK: true, + }, + { + name: "local token4 to token6 input", + inputSlice: []string{"nspath", "comp_tag", "b"}, + want: "comp_tag", + wantOK: true, + }, + { + name: "missing comp tag", + inputSlice: []string{"nspath"}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotOK := configRecommendCompTag(tt.inputSlice) + if gotOK != tt.wantOK { + t.Fatalf("expected ok %t, got %t", tt.wantOK, gotOK) + } + if got != tt.want { + t.Fatalf("expected compTag %q, got %q", tt.want, got) + } + }) + } +} + +func TestFilterMembersByTrimmedPrefix(t *testing.T) { + tests := []struct { + name string + members []string + searchInput string + wantMembers []string + wantMatchInput string + }{ + { + name: "config typo falls back to previous rune", + members: []string{"bay", "base_extend", "model", "rated", "stable", "component"}, + searchInput: "bx", + wantMembers: []string{"bay", "base_extend"}, + wantMatchInput: "b", + }, + { + name: "measurement typo falls back to previous rune", + members: []string{"I_A_rms", "I_B_rms", "U_A_rms"}, + searchInput: "Ix", + wantMembers: []string{"I_A_rms", "I_B_rms"}, + wantMatchInput: "I", + }, + { + name: "no fallback to empty prefix", + members: []string{"bay", "base_extend"}, + searchInput: "x", + wantMembers: []string{}, + wantMatchInput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMembers, gotMatchInput := filterMembersByTrimmedPrefix(tt.members, tt.searchInput) + if !reflect.DeepEqual(gotMembers, tt.wantMembers) { + t.Fatalf("expected members %#v, got %#v", tt.wantMembers, gotMembers) + } + if gotMatchInput != tt.wantMatchInput { + t.Fatalf("expected matched input %q, got %q", tt.wantMatchInput, gotMatchInput) + } + }) + } +} + +func TestDotAndTypoInputsShareTrimmedPrefixFallback(t *testing.T) { + tests := []struct { + name string + dotInput string + typoInput string + members []string + wantSuffix []string + }{ + { + name: "token1 grid", + dotInput: "g.", + typoInput: "gx", + members: []string{"grid000", "grid001", "zone000"}, + wantSuffix: []string{"grid000", "grid001"}, + }, + { + name: "token2 zone", + dotInput: "z.", + typoInput: "zx", + members: []string{"zone000", "zone001", "station000"}, + wantSuffix: []string{"zone000", "zone001"}, + }, + { + name: "token3 station", + dotInput: "s.", + typoInput: "sx", + members: []string{"station000", "station001", "zone000"}, + wantSuffix: []string{"station000", "station001"}, + }, + { + name: "token4 nspath", + dotInput: "1.", + typoInput: "1x", + members: []string{"110kV_TV-demoProject", "110kV_TV-testProject1", "220kV_TV-demoProject"}, + wantSuffix: []string{"110kV_TV-demoProject", "110kV_TV-testProject1"}, + }, + { + name: "token5 component tag", + dotInput: "c.", + typoInput: "cx", + members: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV", "bay"}, + wantSuffix: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV"}, + }, + { + name: "token6 config", + dotInput: "b.", + typoInput: "bx", + members: []string{"bay", "base_extend", "component"}, + wantSuffix: []string{"bay", "base_extend"}, + }, + { + name: "token7 measurement", + dotInput: "I.", + typoInput: "Ix", + members: []string{"IA_rms", "IB_rms", "UA_rms"}, + wantSuffix: []string{"IA_rms", "IB_rms"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dotSearchInput := strings.TrimSuffix(tt.dotInput, ".") + typoSearchInput := trimLastRune(tt.typoInput) + dotMembers, dotMatchedInput := filterMembersByTrimmedPrefix(tt.members, dotSearchInput) + typoMembers, typoMatchedInput := filterMembersByTrimmedPrefix(tt.members, typoSearchInput) + + if dotMatchedInput != typoMatchedInput { + t.Fatalf("expected dot and typo matched input to be equal, dot=%q typo=%q", dotMatchedInput, typoMatchedInput) + } + if !reflect.DeepEqual(dotMembers, typoMembers) { + t.Fatalf("expected dot and typo members to be equal, dot=%#v typo=%#v", dotMembers, typoMembers) + } + if !reflect.DeepEqual(dotMembers, tt.wantSuffix) { + t.Fatalf("expected members %#v, got %#v", tt.wantSuffix, dotMembers) + } + }) + } +} + +func TestExactMatchChecksForInput(t *testing.T) { + tests := []struct { + name string + inputSlice []string + want []exactMatchCheck + }{ + { + name: "token1 can be grid or local nspath", + inputSlice: []string{"g"}, + want: []exactMatchCheck{ + {setKey: constants.RedisAllGridSetKey, member: "g"}, + {setKey: constants.RedisAllCompNSPathSetKey, member: "g"}, + }, + }, + { + name: "token1 token2 can be zone comp tag or nspath meas", + inputSlice: []string{"grid000", "z"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, "grid000"), member: "z"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "grid000"), member: "z"}, + {setKey: constants.RedisAllCompTagSetKey, member: "z"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, "grid000"), member: "z"}, + }, + }, + { + name: "token1 token2 token3 can be station or local config", + inputSlice: []string{"grid000", "zone000", "s"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, "zone000"), member: "s"}, + {setKey: constants.RedisAllConfigSetKey, member: "s"}, + }, + }, + { + name: "token1 token2 token3 token4 can be nspath or local meas", + inputSlice: []string{"grid000", "zone000", "station000", "1"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, "station000"), member: "1"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "zone000"), member: "1"}, + }, + }, + { + name: "token1 through token5 component tag", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "c"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "nspath"), member: "c"}, + {setKey: constants.RedisAllCompTagSetKey, member: "c"}, + }, + }, + { + name: "token1 through token6 config", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"}, + want: []exactMatchCheck{ + {setKey: constants.RedisAllConfigSetKey, member: "b"}, + }, + }, + { + name: "token1 through token7 measurement", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "bay", "I"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), member: "I"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := exactMatchChecksForInput(tt.inputSlice) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected checks %#v, got %#v", tt.want, got) + } + }) + } +} + func TestFallbackSpecificSetKey(t *testing.T) { tests := []struct { name string