refactor: rewrite omnibox inline ghost suggestion state and ranking
- commit
- c93c03d862127eb4d880a09575b428f8480c7717
- author
- brice · · 2026-02-07 06:12 UTC
- parent
- aae7da7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +424 -46
M internal/application/usecase/autocomplete.go +74 -41
| @@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input | |||
| 157 | 157 | return nil | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | for _, fav := range favorites { | |
| 161 | - | if fav == nil || fav.URL == "" { | |
| 162 | - | continue | |
| 163 | - | } | |
| 164 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | |
| 165 | - | if ok { | |
| 166 | - | return &autocomplete.Suggestion{ | |
| 167 | - | FullText: matchedURL, | |
| 168 | - | Suffix: suffix, | |
| 169 | - | Source: autocomplete.SourceFavorite, | |
| 170 | - | Title: fav.Title, | |
| 160 | + | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, | |
| 161 | + | len(favorites), | |
| 162 | + | func(i int) string { | |
| 163 | + | if favorites[i] == nil { | |
| 164 | + | return "" | |
| 171 | 165 | } | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | return nil | |
| 166 | + | return favorites[i].URL | |
| 167 | + | }, | |
| 168 | + | func(i int) string { | |
| 169 | + | if favorites[i] == nil { | |
| 170 | + | return "" | |
| 171 | + | } | |
| 172 | + | return favorites[i].Title | |
| 173 | + | }, | |
| 174 | + | ) | |
| 176 | 175 | } | |
| 177 | 176 | ||
| 178 | 177 | // findHistorySuggestion finds the best matching history entry for the input. | |
| @@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input | |||
| 189 | 188 | return nil | |
| 190 | 189 | } | |
| 191 | 190 | ||
| 192 | - | for _, entry := range entries { | |
| 193 | - | if entry == nil || entry.URL == "" { | |
| 194 | - | continue | |
| 195 | - | } | |
| 196 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL) | |
| 197 | - | if ok { | |
| 198 | - | return &autocomplete.Suggestion{ | |
| 199 | - | FullText: matchedURL, | |
| 200 | - | Suffix: suffix, | |
| 201 | - | Source: autocomplete.SourceHistory, | |
| 202 | - | Title: entry.Title, | |
| 191 | + | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, | |
| 192 | + | len(entries), | |
| 193 | + | func(i int) string { | |
| 194 | + | if entries[i] == nil { | |
| 195 | + | return "" | |
| 203 | 196 | } | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | return nil | |
| 197 | + | return entries[i].URL | |
| 198 | + | }, | |
| 199 | + | func(i int) string { | |
| 200 | + | if entries[i] == nil { | |
| 201 | + | return "" | |
| 202 | + | } | |
| 203 | + | return entries[i].Title | |
| 204 | + | }, | |
| 205 | + | ) | |
| 208 | 206 | } | |
| 209 | 207 | ||
| 210 | 208 | // findBangSuggestion finds the best matching bang shortcut for the input. | |
| @@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str | |||
| 239 | 237 | } | |
| 240 | 238 | ||
| 241 | 239 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | |
| 242 | - | for _, u := range urls { | |
| 243 | - | if u == "" { | |
| 244 | - | continue | |
| 240 | + | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) | |
| 241 | + | if ok { | |
| 242 | + | return &autocomplete.Suggestion{ | |
| 243 | + | FullText: matchedURL, | |
| 244 | + | Suffix: suffix, | |
| 245 | + | Source: source, | |
| 245 | 246 | } | |
| 246 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u) | |
| 247 | - | if ok { | |
| 248 | - | return &autocomplete.Suggestion{ | |
| 249 | - | FullText: matchedURL, | |
| 250 | - | Suffix: suffix, | |
| 251 | - | Source: source, | |
| 252 | - | } | |
| 247 | + | } | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func bestSuggestionFromURLs( | |
| 252 | + | input string, | |
| 253 | + | source autocomplete.SuggestionSource, | |
| 254 | + | count int, | |
| 255 | + | urlAt func(int) string, | |
| 256 | + | titleAt func(int) string, | |
| 257 | + | ) *autocomplete.Suggestion { | |
| 258 | + | if count <= 0 { | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | urls := make([]string, 0, count) | |
| 263 | + | titles := make([]string, 0, count) | |
| 264 | + | for i := 0; i < count; i++ { | |
| 265 | + | url := urlAt(i) | |
| 266 | + | if url == "" { | |
| 267 | + | continue | |
| 253 | 268 | } | |
| 269 | + | urls = append(urls, url) | |
| 270 | + | titles = append(titles, titleAt(i)) | |
| 271 | + | } | |
| 272 | + | if len(urls) == 0 { | |
| 273 | + | return nil | |
| 254 | 274 | } | |
| 255 | 275 | ||
| 256 | - | return nil | |
| 276 | + | suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls) | |
| 277 | + | if !ok { | |
| 278 | + | return nil | |
| 279 | + | } | |
| 280 | + | title := "" | |
| 281 | + | if idx >= 0 && idx < len(titles) { | |
| 282 | + | title = titles[idx] | |
| 283 | + | } | |
| 284 | + | return &autocomplete.Suggestion{ | |
| 285 | + | FullText: matchedURL, | |
| 286 | + | Suffix: suffix, | |
| 287 | + | Source: source, | |
| 288 | + | Title: title, | |
| 289 | + | } | |
| 257 | 290 | } | |
| 258 | 291 | ||
| 259 | 292 | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | |
| @@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input | |||||
| 157 | return nil | 157 | return nil | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | for _, fav := range favorites { | 160 | + | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, |
| 161 | - | if fav == nil || fav.URL == "" { | 161 | + | len(favorites), |
| 162 | - | continue | 162 | + | func(i int) string { |
| 163 | - | } | 163 | + | if favorites[i] == nil { |
| 164 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | 164 | + | return "" |
| 165 | - | if ok { | |||
| 166 | - | return &autocomplete.Suggestion{ | |||
| 167 | - | FullText: matchedURL, | |||
| 168 | - | Suffix: suffix, | |||
| 169 | - | Source: autocomplete.SourceFavorite, | |||
| 170 | - | Title: fav.Title, | |||
| 171 | } | 165 | } | ||
| 172 | - | } | 166 | + | return favorites[i].URL |
| 173 | - | } | 167 | + | }, |
| 174 | - | 168 | + | func(i int) string { | |
| 175 | - | return nil | 169 | + | if favorites[i] == nil { |
| 170 | + | return "" | |||
| 171 | + | } | |||
| 172 | + | return favorites[i].Title | |||
| 173 | + | }, | |||
| 174 | + | ) | |||
| 176 | } | 175 | } | ||
| 177 | 176 | ||||
| 178 | // findHistorySuggestion finds the best matching history entry for the input. | 177 | // findHistorySuggestion finds the best matching history entry for the input. | ||
| @@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input | |||||
| 189 | return nil | 188 | return nil | ||
| 190 | } | 189 | } | ||
| 191 | 190 | ||||
| 192 | - | for _, entry := range entries { | 191 | + | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, |
| 193 | - | if entry == nil || entry.URL == "" { | 192 | + | len(entries), |
| 194 | - | continue | 193 | + | func(i int) string { |
| 195 | - | } | 194 | + | if entries[i] == nil { |
| 196 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL) | 195 | + | return "" |
| 197 | - | if ok { | |||
| 198 | - | return &autocomplete.Suggestion{ | |||
| 199 | - | FullText: matchedURL, | |||
| 200 | - | Suffix: suffix, | |||
| 201 | - | Source: autocomplete.SourceHistory, | |||
| 202 | - | Title: entry.Title, | |||
| 203 | } | 196 | } | ||
| 204 | - | } | 197 | + | return entries[i].URL |
| 205 | - | } | 198 | + | }, |
| 206 | - | 199 | + | func(i int) string { | |
| 207 | - | return nil | 200 | + | if entries[i] == nil { |
| 201 | + | return "" | |||
| 202 | + | } | |||
| 203 | + | return entries[i].Title | |||
| 204 | + | }, | |||
| 205 | + | ) | |||
| 208 | } | 206 | } | ||
| 209 | 207 | ||||
| 210 | // findBangSuggestion finds the best matching bang shortcut for the input. | 208 | // findBangSuggestion finds the best matching bang shortcut for the input. | ||
| @@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str | |||||
| 239 | } | 237 | } | ||
| 240 | 238 | ||||
| 241 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | 239 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | ||
| 242 | - | for _, u := range urls { | 240 | + | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) |
| 243 | - | if u == "" { | 241 | + | if ok { |
| 244 | - | continue | 242 | + | return &autocomplete.Suggestion{ |
| 243 | + | FullText: matchedURL, | |||
| 244 | + | Suffix: suffix, | |||
| 245 | + | Source: source, | |||
| 245 | } | 246 | } | ||
| 246 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u) | 247 | + | } |
| 247 | - | if ok { | 248 | + | return nil |
| 248 | - | return &autocomplete.Suggestion{ | 249 | + | } |
| 249 | - | FullText: matchedURL, | 250 | + | |
| 250 | - | Suffix: suffix, | 251 | + | func bestSuggestionFromURLs( |
| 251 | - | Source: source, | 252 | + | input string, |
| 252 | - | } | 253 | + | source autocomplete.SuggestionSource, |
| 254 | + | count int, | |||
| 255 | + | urlAt func(int) string, | |||
| 256 | + | titleAt func(int) string, | |||
| 257 | + | ) *autocomplete.Suggestion { | |||
| 258 | + | if count <= 0 { | |||
| 259 | + | return nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | urls := make([]string, 0, count) | |||
| 263 | + | titles := make([]string, 0, count) | |||
| 264 | + | for i := 0; i < count; i++ { | |||
| 265 | + | url := urlAt(i) | |||
| 266 | + | if url == "" { | |||
| 267 | + | continue | |||
| 253 | } | 268 | } | ||
| 269 | + | urls = append(urls, url) | |||
| 270 | + | titles = append(titles, titleAt(i)) | |||
| 271 | + | } | |||
| 272 | + | if len(urls) == 0 { | |||
| 273 | + | return nil | |||
| 254 | } | 274 | } | ||
| 255 | 275 | ||||
| 256 | - | return nil | 276 | + | suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls) |
| 277 | + | if !ok { | |||
| 278 | + | return nil | |||
| 279 | + | } | |||
| 280 | + | title := "" | |||
| 281 | + | if idx >= 0 && idx < len(titles) { | |||
| 282 | + | title = titles[idx] | |||
| 283 | + | } | |||
| 284 | + | return &autocomplete.Suggestion{ | |||
| 285 | + | FullText: matchedURL, | |||
| 286 | + | Suffix: suffix, | |||
| 287 | + | Source: source, | |||
| 288 | + | Title: title, | |||
| 289 | + | } | |||
| 257 | } | 290 | } | ||
| 258 | 291 | ||||
| 259 | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | 292 | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | ||
M internal/application/usecase/autocomplete_test.go +21 -2
| @@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) { | |||
| 55 | 55 | assert.True(t, output.Found) | |
| 56 | 56 | assert.NotNil(t, output.Suggestion) | |
| 57 | 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |
| 58 | - | assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText) | |
| 59 | - | assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix) | |
| 58 | + | assert.Equal(t, "github.com", output.Suggestion.FullText) | |
| 59 | + | assert.Equal(t, ".com", output.Suggestion.Suffix) | |
| 60 | 60 | assert.Equal(t, "My Repo", output.Suggestion.Title) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| @@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) { | |||
| 167 | 167 | assert.Equal(t, "a.dev", suggestion.Suffix) | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) { | |
| 171 | + | ctx := testContext() | |
| 172 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |
| 173 | + | ||
| 174 | + | urls := []string{ | |
| 175 | + | "https://www.google.com/url?q=https://dashboard.stripe.com", | |
| 176 | + | "https://google.com", | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{ | |
| 180 | + | VisibleURLs: urls, | |
| 181 | + | AllowBangs: false, | |
| 182 | + | }) | |
| 183 | + | ||
| 184 | + | require.NotNil(t, suggestion) | |
| 185 | + | assert.Equal(t, "google.com", suggestion.FullText) | |
| 186 | + | assert.Equal(t, "gle.com", suggestion.Suffix) | |
| 187 | + | } | |
| 188 | + | ||
| 170 | 189 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | |
| 171 | 190 | ctx := testContext() | |
| 172 | 191 | ||
| @@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) { | |||||
| 55 | assert.True(t, output.Found) | 55 | assert.True(t, output.Found) | ||
| 56 | assert.NotNil(t, output.Suggestion) | 56 | assert.NotNil(t, output.Suggestion) | ||
| 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | ||
| 58 | - | assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText) | 58 | + | assert.Equal(t, "github.com", output.Suggestion.FullText) |
| 59 | - | assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix) | 59 | + | assert.Equal(t, ".com", output.Suggestion.Suffix) |
| 60 | assert.Equal(t, "My Repo", output.Suggestion.Title) | 60 | assert.Equal(t, "My Repo", output.Suggestion.Title) | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| @@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) { | |||||
| 167 | assert.Equal(t, "a.dev", suggestion.Suffix) | 167 | assert.Equal(t, "a.dev", suggestion.Suffix) | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | + | func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) { | |||
| 171 | + | ctx := testContext() | |||
| 172 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 173 | + | ||||
| 174 | + | urls := []string{ | |||
| 175 | + | "https://www.google.com/url?q=https://dashboard.stripe.com", | |||
| 176 | + | "https://google.com", | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{ | |||
| 180 | + | VisibleURLs: urls, | |||
| 181 | + | AllowBangs: false, | |||
| 182 | + | }) | |||
| 183 | + | ||||
| 184 | + | require.NotNil(t, suggestion) | |||
| 185 | + | assert.Equal(t, "google.com", suggestion.FullText) | |||
| 186 | + | assert.Equal(t, "gle.com", suggestion.Suffix) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 170 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | 189 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | ||
| 171 | ctx := testContext() | 190 | ctx := testContext() | ||
| 172 | 191 | ||||
M internal/domain/autocomplete/autocomplete.go +123 -1
| @@ -1,7 +1,11 @@ | |||
| 1 | 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | |
| 2 | 2 | package autocomplete | |
| 3 | 3 | ||
| 4 | - | import "strings" | |
| 4 | + | import ( | |
| 5 | + | stdurl "net/url" | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ) | |
| 5 | 9 | ||
| 6 | 10 | // SuggestionSource indicates the origin of an autocomplete suggestion. | |
| 7 | 11 | type SuggestionSource int | |
| @@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin | |||
| 78 | 82 | ||
| 79 | 83 | return "", "", false | |
| 80 | 84 | } | |
| 85 | + | ||
| 86 | + | // BestURLCompletion returns the best completion candidate from a list of URLs. | |
| 87 | + | // For host-like queries (for example "goo"), it prefers short host completions | |
| 88 | + | // over deep redirect paths. | |
| 89 | + | func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) { | |
| 90 | + | suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls) | |
| 91 | + | return suffix, matchedURL, ok | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // BestURLCompletionWithIndex returns the best completion candidate and its source URL index. | |
| 95 | + | func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) { | |
| 96 | + | bestScore := completionScore{ | |
| 97 | + | valid: false, | |
| 98 | + | } | |
| 99 | + | bestSuffix := "" | |
| 100 | + | bestMatched := "" | |
| 101 | + | bestIndex := -1 | |
| 102 | + | ||
| 103 | + | for idx, rawURL := range urls { | |
| 104 | + | if rawURL == "" { | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL) | |
| 109 | + | if !candidateOK { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | score := rankCompletion(candidateText, candidateSuffix, idx) | |
| 114 | + | if !bestScore.valid || score.less(bestScore) { | |
| 115 | + | bestScore = score | |
| 116 | + | bestSuffix = candidateSuffix | |
| 117 | + | bestMatched = candidateText | |
| 118 | + | bestIndex = idx | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | if !bestScore.valid { | |
| 123 | + | return "", "", -1, false | |
| 124 | + | } | |
| 125 | + | return bestSuffix, bestMatched, bestIndex, true | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) { | |
| 129 | + | if looksLikeHostInput(input) { | |
| 130 | + | if host := hostForCompletion(rawURL); host != "" { | |
| 131 | + | for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} { | |
| 132 | + | if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK { | |
| 133 | + | return candidate, completionSuffix, true | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL) | |
| 140 | + | if !completionOK { | |
| 141 | + | return "", "", false | |
| 142 | + | } | |
| 143 | + | return completionMatched, completionSuffix, true | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func looksLikeHostInput(input string) bool { | |
| 147 | + | if input == "" || strings.HasPrefix(input, "!") { | |
| 148 | + | return false | |
| 149 | + | } | |
| 150 | + | return !strings.ContainsAny(input, "/?#=& ") | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func hostForCompletion(raw string) string { | |
| 154 | + | if raw == "" { | |
| 155 | + | return "" | |
| 156 | + | } | |
| 157 | + | if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" { | |
| 158 | + | return strings.ToLower(parsed.Hostname()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | trimmed := strings.ToLower(StripProtocol(raw)) | |
| 162 | + | for _, sep := range []string{"/", "?", "#"} { | |
| 163 | + | if idx := strings.Index(trimmed, sep); idx >= 0 { | |
| 164 | + | trimmed = trimmed[:idx] | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return strings.TrimSpace(trimmed) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type completionScore struct { | |
| 171 | + | valid bool | |
| 172 | + | hasPathOrArgs bool | |
| 173 | + | suffixLen int | |
| 174 | + | totalLen int | |
| 175 | + | index int | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func rankCompletion(matchedURL, suffix string, index int) completionScore { | |
| 179 | + | return completionScore{ | |
| 180 | + | valid: true, | |
| 181 | + | hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"), | |
| 182 | + | suffixLen: utf8.RuneCountInString(suffix), | |
| 183 | + | totalLen: utf8.RuneCountInString(matchedURL), | |
| 184 | + | index: index, | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (s completionScore) less(other completionScore) bool { | |
| 189 | + | if s.hasPathOrArgs != other.hasPathOrArgs { | |
| 190 | + | return !s.hasPathOrArgs | |
| 191 | + | } | |
| 192 | + | if s.index != other.index { | |
| 193 | + | return s.index < other.index | |
| 194 | + | } | |
| 195 | + | if s.suffixLen != other.suffixLen { | |
| 196 | + | return s.suffixLen < other.suffixLen | |
| 197 | + | } | |
| 198 | + | if s.totalLen != other.totalLen { | |
| 199 | + | return s.totalLen < other.totalLen | |
| 200 | + | } | |
| 201 | + | return false | |
| 202 | + | } | |
| @@ -1,7 +1,11 @@ | |||||
| 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | ||
| 2 | package autocomplete | 2 | package autocomplete | ||
| 3 | 3 | ||||
| 4 | - | import "strings" | 4 | + | import ( |
| 5 | + | stdurl "net/url" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ) | |||
| 5 | 9 | ||||
| 6 | // SuggestionSource indicates the origin of an autocomplete suggestion. | 10 | // SuggestionSource indicates the origin of an autocomplete suggestion. | ||
| 7 | type SuggestionSource int | 11 | type SuggestionSource int | ||
| @@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin | |||||
| 78 | 82 | ||||
| 79 | return "", "", false | 83 | return "", "", false | ||
| 80 | } | 84 | } | ||
| 85 | + | ||||
| 86 | + | // BestURLCompletion returns the best completion candidate from a list of URLs. | |||
| 87 | + | // For host-like queries (for example "goo"), it prefers short host completions | |||
| 88 | + | // over deep redirect paths. | |||
| 89 | + | func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) { | |||
| 90 | + | suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls) | |||
| 91 | + | return suffix, matchedURL, ok | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // BestURLCompletionWithIndex returns the best completion candidate and its source URL index. | |||
| 95 | + | func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) { | |||
| 96 | + | bestScore := completionScore{ | |||
| 97 | + | valid: false, | |||
| 98 | + | } | |||
| 99 | + | bestSuffix := "" | |||
| 100 | + | bestMatched := "" | |||
| 101 | + | bestIndex := -1 | |||
| 102 | + | ||||
| 103 | + | for idx, rawURL := range urls { | |||
| 104 | + | if rawURL == "" { | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL) | |||
| 109 | + | if !candidateOK { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | score := rankCompletion(candidateText, candidateSuffix, idx) | |||
| 114 | + | if !bestScore.valid || score.less(bestScore) { | |||
| 115 | + | bestScore = score | |||
| 116 | + | bestSuffix = candidateSuffix | |||
| 117 | + | bestMatched = candidateText | |||
| 118 | + | bestIndex = idx | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if !bestScore.valid { | |||
| 123 | + | return "", "", -1, false | |||
| 124 | + | } | |||
| 125 | + | return bestSuffix, bestMatched, bestIndex, true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) { | |||
| 129 | + | if looksLikeHostInput(input) { | |||
| 130 | + | if host := hostForCompletion(rawURL); host != "" { | |||
| 131 | + | for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} { | |||
| 132 | + | if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK { | |||
| 133 | + | return candidate, completionSuffix, true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL) | |||
| 140 | + | if !completionOK { | |||
| 141 | + | return "", "", false | |||
| 142 | + | } | |||
| 143 | + | return completionMatched, completionSuffix, true | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func looksLikeHostInput(input string) bool { | |||
| 147 | + | if input == "" || strings.HasPrefix(input, "!") { | |||
| 148 | + | return false | |||
| 149 | + | } | |||
| 150 | + | return !strings.ContainsAny(input, "/?#=& ") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func hostForCompletion(raw string) string { | |||
| 154 | + | if raw == "" { | |||
| 155 | + | return "" | |||
| 156 | + | } | |||
| 157 | + | if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" { | |||
| 158 | + | return strings.ToLower(parsed.Hostname()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | trimmed := strings.ToLower(StripProtocol(raw)) | |||
| 162 | + | for _, sep := range []string{"/", "?", "#"} { | |||
| 163 | + | if idx := strings.Index(trimmed, sep); idx >= 0 { | |||
| 164 | + | trimmed = trimmed[:idx] | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return strings.TrimSpace(trimmed) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | type completionScore struct { | |||
| 171 | + | valid bool | |||
| 172 | + | hasPathOrArgs bool | |||
| 173 | + | suffixLen int | |||
| 174 | + | totalLen int | |||
| 175 | + | index int | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func rankCompletion(matchedURL, suffix string, index int) completionScore { | |||
| 179 | + | return completionScore{ | |||
| 180 | + | valid: true, | |||
| 181 | + | hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"), | |||
| 182 | + | suffixLen: utf8.RuneCountInString(suffix), | |||
| 183 | + | totalLen: utf8.RuneCountInString(matchedURL), | |||
| 184 | + | index: index, | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (s completionScore) less(other completionScore) bool { | |||
| 189 | + | if s.hasPathOrArgs != other.hasPathOrArgs { | |||
| 190 | + | return !s.hasPathOrArgs | |||
| 191 | + | } | |||
| 192 | + | if s.index != other.index { | |||
| 193 | + | return s.index < other.index | |||
| 194 | + | } | |||
| 195 | + | if s.suffixLen != other.suffixLen { | |||
| 196 | + | return s.suffixLen < other.suffixLen | |||
| 197 | + | } | |||
| 198 | + | if s.totalLen != other.totalLen { | |||
| 199 | + | return s.totalLen < other.totalLen | |||
| 200 | + | } | |||
| 201 | + | return false | |||
| 202 | + | } | |||
M internal/domain/autocomplete/autocomplete_test.go +53 -0
| @@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) { | |||
| 266 | 266 | }) | |
| 267 | 267 | } | |
| 268 | 268 | } | |
| 269 | + | ||
| 270 | + | func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) { | |
| 271 | + | urls := []string{ | |
| 272 | + | "https://www.google.com/url?q=https://dashboard.stripe.com/auth", | |
| 273 | + | "https://google.com", | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |
| 277 | + | if !ok { | |
| 278 | + | t.Fatalf("expected completion") | |
| 279 | + | } | |
| 280 | + | if matchedURL != "google.com" { | |
| 281 | + | t.Fatalf("expected host completion, got %q", matchedURL) | |
| 282 | + | } | |
| 283 | + | if suffix != "gle.com" { | |
| 284 | + | t.Fatalf("expected host suffix, got %q", suffix) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) { | |
| 289 | + | urls := []string{ | |
| 290 | + | "https://google.com/url?q=https://example.com", | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls) | |
| 294 | + | if !ok { | |
| 295 | + | t.Fatalf("expected completion") | |
| 296 | + | } | |
| 297 | + | if matchedURL != "google.com/url?q=https://example.com" { | |
| 298 | + | t.Fatalf("unexpected matched URL: %q", matchedURL) | |
| 299 | + | } | |
| 300 | + | if suffix != "rl?q=https://example.com" { | |
| 301 | + | t.Fatalf("unexpected suffix: %q", suffix) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) { | |
| 306 | + | urls := []string{ | |
| 307 | + | "https://example.org", | |
| 308 | + | "https://google.com/maps", | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |
| 312 | + | if !ok { | |
| 313 | + | t.Fatalf("expected completion from second URL") | |
| 314 | + | } | |
| 315 | + | if matchedURL != "google.com" { | |
| 316 | + | t.Fatalf("expected host completion from second URL, got %q", matchedURL) | |
| 317 | + | } | |
| 318 | + | if suffix != "gle.com" { | |
| 319 | + | t.Fatalf("unexpected suffix: %q", suffix) | |
| 320 | + | } | |
| 321 | + | } | |
| @@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) { | |||||
| 266 | }) | 266 | }) | ||
| 267 | } | 267 | } | ||
| 268 | } | 268 | } | ||
| 269 | + | ||||
| 270 | + | func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) { | |||
| 271 | + | urls := []string{ | |||
| 272 | + | "https://www.google.com/url?q=https://dashboard.stripe.com/auth", | |||
| 273 | + | "https://google.com", | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |||
| 277 | + | if !ok { | |||
| 278 | + | t.Fatalf("expected completion") | |||
| 279 | + | } | |||
| 280 | + | if matchedURL != "google.com" { | |||
| 281 | + | t.Fatalf("expected host completion, got %q", matchedURL) | |||
| 282 | + | } | |||
| 283 | + | if suffix != "gle.com" { | |||
| 284 | + | t.Fatalf("expected host suffix, got %q", suffix) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) { | |||
| 289 | + | urls := []string{ | |||
| 290 | + | "https://google.com/url?q=https://example.com", | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls) | |||
| 294 | + | if !ok { | |||
| 295 | + | t.Fatalf("expected completion") | |||
| 296 | + | } | |||
| 297 | + | if matchedURL != "google.com/url?q=https://example.com" { | |||
| 298 | + | t.Fatalf("unexpected matched URL: %q", matchedURL) | |||
| 299 | + | } | |||
| 300 | + | if suffix != "rl?q=https://example.com" { | |||
| 301 | + | t.Fatalf("unexpected suffix: %q", suffix) | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) { | |||
| 306 | + | urls := []string{ | |||
| 307 | + | "https://example.org", | |||
| 308 | + | "https://google.com/maps", | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |||
| 312 | + | if !ok { | |||
| 313 | + | t.Fatalf("expected completion from second URL") | |||
| 314 | + | } | |||
| 315 | + | if matchedURL != "google.com" { | |||
| 316 | + | t.Fatalf("expected host completion from second URL, got %q", matchedURL) | |||
| 317 | + | } | |||
| 318 | + | if suffix != "gle.com" { | |||
| 319 | + | t.Fatalf("unexpected suffix: %q", suffix) | |||
| 320 | + | } | |||
| 321 | + | } | |||
M internal/ui/component/omnibox.go +42 -2
| @@ -798,6 +798,10 @@ func (o *Omnibox) onEntryChanged() { | |||
| 798 | 798 | return | |
| 799 | 799 | } | |
| 800 | 800 | ||
| 801 | + | if o.isGhostProgrammaticEcho(entryText) { | |
| 802 | + | return | |
| 803 | + | } | |
| 804 | + | ||
| 801 | 805 | // Reset navigation state when user types - space should type, not toggle favorite | |
| 802 | 806 | o.mu.Lock() | |
| 803 | 807 | o.hasNavigated = false | |
| @@ -823,6 +827,35 @@ func (o *Omnibox) onEntryChanged() { | |||
| 823 | 827 | o.debounceMu.Unlock() | |
| 824 | 828 | } | |
| 825 | 829 | ||
| 830 | + | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 831 | + | o.mu.RLock() | |
| 832 | + | hasGhost := o.hasGhostText | |
| 833 | + | realInput := o.realInput | |
| 834 | + | ghostFullText := o.ghostFullText | |
| 835 | + | o.mu.RUnlock() | |
| 836 | + | ||
| 837 | + | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | |
| 838 | + | return false | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | selectionStart := 0 | |
| 842 | + | selectionEnd := 0 | |
| 843 | + | hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd) | |
| 844 | + | return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection) | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool { | |
| 848 | + | if !hasSelection { | |
| 849 | + | return false | |
| 850 | + | } | |
| 851 | + | prefixRunes := utf8.RuneCountInString(realInput) | |
| 852 | + | fullRunes := utf8.RuneCountInString(ghostFullText) | |
| 853 | + | if prefixRunes < 0 || fullRunes <= prefixRunes { | |
| 854 | + | return false | |
| 855 | + | } | |
| 856 | + | return selectionStart == prefixRunes && selectionEnd == fullRunes | |
| 857 | + | } | |
| 858 | + | ||
| 826 | 859 | // setGhostText applies inline completion using entry selection. | |
| 827 | 860 | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | |
| 828 | 861 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |
| @@ -922,11 +955,11 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||
| 922 | 955 | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 923 | 956 | func (o *Omnibox) acceptGhostCompletion() { | |
| 924 | 957 | o.mu.Lock() | |
| 925 | - | if !o.hasGhostText || o.ghostFullText == "" { | |
| 958 | + | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | |
| 959 | + | if !accepted { | |
| 926 | 960 | o.mu.Unlock() | |
| 927 | 961 | return | |
| 928 | 962 | } | |
| 929 | - | fullText := o.ghostFullText | |
| 930 | 963 | o.isAcceptingGhost = true | |
| 931 | 964 | o.realInput = fullText | |
| 932 | 965 | o.ghostToken++ | |
| @@ -948,6 +981,13 @@ func (o *Omnibox) acceptGhostCompletion() { | |||
| 948 | 981 | o.mu.Unlock() | |
| 949 | 982 | } | |
| 950 | 983 | ||
| 984 | + | func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) { | |
| 985 | + | if !hasGhost || ghostFullText == "" { | |
| 986 | + | return "", false | |
| 987 | + | } | |
| 988 | + | return ghostFullText, true | |
| 989 | + | } | |
| 990 | + | ||
| 951 | 991 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| 952 | 992 | // When a row is selected via arrow keys, show the URL as ghost text even if | |
| 953 | 993 | // the user's input isn't a prefix - this shows what Tab would fill in. | |
| @@ -798,6 +798,10 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 798 | return | 798 | return | ||
| 799 | } | 799 | } | ||
| 800 | 800 | ||||
| 801 | + | if o.isGhostProgrammaticEcho(entryText) { | |||
| 802 | + | return | |||
| 803 | + | } | |||
| 804 | + | ||||
| 801 | // Reset navigation state when user types - space should type, not toggle favorite | 805 | // Reset navigation state when user types - space should type, not toggle favorite | ||
| 802 | o.mu.Lock() | 806 | o.mu.Lock() | ||
| 803 | o.hasNavigated = false | 807 | o.hasNavigated = false | ||
| @@ -823,6 +827,35 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 823 | o.debounceMu.Unlock() | 827 | o.debounceMu.Unlock() | ||
| 824 | } | 828 | } | ||
| 825 | 829 | ||||
| 830 | + | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |||
| 831 | + | o.mu.RLock() | |||
| 832 | + | hasGhost := o.hasGhostText | |||
| 833 | + | realInput := o.realInput | |||
| 834 | + | ghostFullText := o.ghostFullText | |||
| 835 | + | o.mu.RUnlock() | |||
| 836 | + | ||||
| 837 | + | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | |||
| 838 | + | return false | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | selectionStart := 0 | |||
| 842 | + | selectionEnd := 0 | |||
| 843 | + | hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd) | |||
| 844 | + | return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection) | |||
| 845 | + | } | |||
| 846 | + | ||||
| 847 | + | func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool { | |||
| 848 | + | if !hasSelection { | |||
| 849 | + | return false | |||
| 850 | + | } | |||
| 851 | + | prefixRunes := utf8.RuneCountInString(realInput) | |||
| 852 | + | fullRunes := utf8.RuneCountInString(ghostFullText) | |||
| 853 | + | if prefixRunes < 0 || fullRunes <= prefixRunes { | |||
| 854 | + | return false | |||
| 855 | + | } | |||
| 856 | + | return selectionStart == prefixRunes && selectionEnd == fullRunes | |||
| 857 | + | } | |||
| 858 | + | ||||
| 826 | // setGhostText applies inline completion using entry selection. | 859 | // setGhostText applies inline completion using entry selection. | ||
| 827 | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | 860 | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | ||
| 828 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | 861 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | ||
| @@ -922,11 +955,11 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||||
| 922 | // acceptGhostCompletion accepts the ghost text and fills the input. | 955 | // acceptGhostCompletion accepts the ghost text and fills the input. | ||
| 923 | func (o *Omnibox) acceptGhostCompletion() { | 956 | func (o *Omnibox) acceptGhostCompletion() { | ||
| 924 | o.mu.Lock() | 957 | o.mu.Lock() | ||
| 925 | - | if !o.hasGhostText || o.ghostFullText == "" { | 958 | + | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) |
| 959 | + | if !accepted { | |||
| 926 | o.mu.Unlock() | 960 | o.mu.Unlock() | ||
| 927 | return | 961 | return | ||
| 928 | } | 962 | } | ||
| 929 | - | fullText := o.ghostFullText | |||
| 930 | o.isAcceptingGhost = true | 963 | o.isAcceptingGhost = true | ||
| 931 | o.realInput = fullText | 964 | o.realInput = fullText | ||
| 932 | o.ghostToken++ | 965 | o.ghostToken++ | ||
| @@ -948,6 +981,13 @@ func (o *Omnibox) acceptGhostCompletion() { | |||||
| 948 | o.mu.Unlock() | 981 | o.mu.Unlock() | ||
| 949 | } | 982 | } | ||
| 950 | 983 | ||||
| 984 | + | func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) { | |||
| 985 | + | if !hasGhost || ghostFullText == "" { | |||
| 986 | + | return "", false | |||
| 987 | + | } | |||
| 988 | + | return ghostFullText, true | |||
| 989 | + | } | |||
| 990 | + | ||||
| 951 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | 991 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | ||
| 952 | // When a row is selected via arrow keys, show the URL as ghost text even if | 992 | // When a row is selected via arrow keys, show the URL as ghost text even if | ||
| 953 | // the user's input isn't a prefix - this shows what Tab would fill in. | 993 | // the user's input isn't a prefix - this shows what Tab would fill in. | ||
A internal/ui/component/omnibox_ghost_state_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestInlineGhostSelection(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | realInput string | |
| 9 | + | ghostFullText string | |
| 10 | + | selectionStart int | |
| 11 | + | selectionEnd int | |
| 12 | + | hasSelection bool | |
| 13 | + | want bool | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "selected suffix matches typed prefix", | |
| 17 | + | realInput: "goo", | |
| 18 | + | ghostFullText: "google.com", | |
| 19 | + | selectionStart: 3, | |
| 20 | + | selectionEnd: 10, | |
| 21 | + | hasSelection: true, | |
| 22 | + | want: true, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "no selection means no ghost suffix", | |
| 26 | + | realInput: "goo", | |
| 27 | + | ghostFullText: "google.com", | |
| 28 | + | selectionStart: 10, | |
| 29 | + | selectionEnd: 10, | |
| 30 | + | hasSelection: false, | |
| 31 | + | want: false, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "collapsed selection should not be treated as ghost", | |
| 35 | + | realInput: "goo", | |
| 36 | + | ghostFullText: "google.com", | |
| 37 | + | selectionStart: 10, | |
| 38 | + | selectionEnd: 10, | |
| 39 | + | hasSelection: true, | |
| 40 | + | want: false, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | name: "wrong selection range should not be treated as ghost", | |
| 44 | + | realInput: "goo", | |
| 45 | + | ghostFullText: "google.com", | |
| 46 | + | selectionStart: 1, | |
| 47 | + | selectionEnd: 10, | |
| 48 | + | hasSelection: true, | |
| 49 | + | want: false, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | got := isInlineGhostSelection( | |
| 56 | + | tt.realInput, | |
| 57 | + | tt.ghostFullText, | |
| 58 | + | tt.selectionStart, | |
| 59 | + | tt.selectionEnd, | |
| 60 | + | tt.hasSelection, | |
| 61 | + | ) | |
| 62 | + | if got != tt.want { | |
| 63 | + | t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want) | |
| 64 | + | } | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestAcceptedGhostInput(t *testing.T) { | |
| 70 | + | tests := []struct { | |
| 71 | + | name string | |
| 72 | + | hasGhost bool | |
| 73 | + | ghostFullText string | |
| 74 | + | wantText string | |
| 75 | + | wantAccepted bool | |
| 76 | + | }{ | |
| 77 | + | { | |
| 78 | + | name: "accepts explicit ghost", | |
| 79 | + | hasGhost: true, | |
| 80 | + | ghostFullText: "google.com", | |
| 81 | + | wantText: "google.com", | |
| 82 | + | wantAccepted: true, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "rejects without ghost", | |
| 86 | + | hasGhost: false, | |
| 87 | + | ghostFullText: "google.com", | |
| 88 | + | wantText: "", | |
| 89 | + | wantAccepted: false, | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | name: "rejects empty ghost text", | |
| 93 | + | hasGhost: true, | |
| 94 | + | ghostFullText: "", | |
| 95 | + | wantText: "", | |
| 96 | + | wantAccepted: false, | |
| 97 | + | }, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, tt := range tests { | |
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |
| 102 | + | gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText) | |
| 103 | + | if gotText != tt.wantText { | |
| 104 | + | t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText) | |
| 105 | + | } | |
| 106 | + | if gotAccepted != tt.wantAccepted { | |
| 107 | + | t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestInlineGhostSelection(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | realInput string | |||
| 9 | + | ghostFullText string | |||
| 10 | + | selectionStart int | |||
| 11 | + | selectionEnd int | |||
| 12 | + | hasSelection bool | |||
| 13 | + | want bool | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "selected suffix matches typed prefix", | |||
| 17 | + | realInput: "goo", | |||
| 18 | + | ghostFullText: "google.com", | |||
| 19 | + | selectionStart: 3, | |||
| 20 | + | selectionEnd: 10, | |||
| 21 | + | hasSelection: true, | |||
| 22 | + | want: true, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "no selection means no ghost suffix", | |||
| 26 | + | realInput: "goo", | |||
| 27 | + | ghostFullText: "google.com", | |||
| 28 | + | selectionStart: 10, | |||
| 29 | + | selectionEnd: 10, | |||
| 30 | + | hasSelection: false, | |||
| 31 | + | want: false, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "collapsed selection should not be treated as ghost", | |||
| 35 | + | realInput: "goo", | |||
| 36 | + | ghostFullText: "google.com", | |||
| 37 | + | selectionStart: 10, | |||
| 38 | + | selectionEnd: 10, | |||
| 39 | + | hasSelection: true, | |||
| 40 | + | want: false, | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "wrong selection range should not be treated as ghost", | |||
| 44 | + | realInput: "goo", | |||
| 45 | + | ghostFullText: "google.com", | |||
| 46 | + | selectionStart: 1, | |||
| 47 | + | selectionEnd: 10, | |||
| 48 | + | hasSelection: true, | |||
| 49 | + | want: false, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | got := isInlineGhostSelection( | |||
| 56 | + | tt.realInput, | |||
| 57 | + | tt.ghostFullText, | |||
| 58 | + | tt.selectionStart, | |||
| 59 | + | tt.selectionEnd, | |||
| 60 | + | tt.hasSelection, | |||
| 61 | + | ) | |||
| 62 | + | if got != tt.want { | |||
| 63 | + | t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want) | |||
| 64 | + | } | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestAcceptedGhostInput(t *testing.T) { | |||
| 70 | + | tests := []struct { | |||
| 71 | + | name string | |||
| 72 | + | hasGhost bool | |||
| 73 | + | ghostFullText string | |||
| 74 | + | wantText string | |||
| 75 | + | wantAccepted bool | |||
| 76 | + | }{ | |||
| 77 | + | { | |||
| 78 | + | name: "accepts explicit ghost", | |||
| 79 | + | hasGhost: true, | |||
| 80 | + | ghostFullText: "google.com", | |||
| 81 | + | wantText: "google.com", | |||
| 82 | + | wantAccepted: true, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "rejects without ghost", | |||
| 86 | + | hasGhost: false, | |||
| 87 | + | ghostFullText: "google.com", | |||
| 88 | + | wantText: "", | |||
| 89 | + | wantAccepted: false, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "rejects empty ghost text", | |||
| 93 | + | hasGhost: true, | |||
| 94 | + | ghostFullText: "", | |||
| 95 | + | wantText: "", | |||
| 96 | + | wantAccepted: false, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tt := range tests { | |||
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | + | gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText) | |||
| 103 | + | if gotText != tt.wantText { | |||
| 104 | + | t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText) | |||
| 105 | + | } | |||
| 106 | + | if gotAccepted != tt.wantAccepted { | |||
| 107 | + | t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||