package match import ( "context" "reflect" "strings" "testing" "git.totmin.ru/en2zmax/addrkit/internal/domain/address" ) // staticRewriter returns a fixed list of variants, emulating the LLM adapter. type staticRewriter []string func (s staticRewriter) Rewrite(context.Context, string) ([]string, error) { return s, nil } // TestRewriteInheritsDetectedCity guards the multi-city LLM path: the embedded // prompt tells the model to strip administrative words, so a correct variant // usually has no city. It must inherit the city detected in the original query, // while a variant naming a different city must still be rejected. func TestRewriteInheritsDetectedCity(t *testing.T) { parser := address.NewParser(address.NewDictionary(matchCityDictionary()), "") entries := cityEntries(t, parser, [2]string{"Новосибирск", "Кошурникова улица 5"}, [2]string{"Бердск", "Кошурникова улица 5"}, ) // A threshold above any typo score forces the deterministic path to miss. ix := NewIndex("test", entries, Options{FuzzyThreshold: 0.999, MaxCandidates: 5}) cityless := NewResolverWithOptions(ix, staticRewriter{"Кошурникова 5"}, ResolverOptions{Parser: parser}) res := cityless.ResolveInCity(context.Background(), "нск кошурникиова 5", "") if res.Status != address.StatusFound || res.City != "Новосибирск" { t.Fatalf("cityless variant: %+v", res) } conflict := NewResolverWithOptions(ix, staticRewriter{"Бердск, Кошурникова 5"}, ResolverOptions{Parser: parser}) res = conflict.ResolveInCity(context.Background(), "нск кошурникиова 5", "") if res.Status == address.StatusFound { t.Fatalf("conflicting city variant was accepted: %+v", res) } } func matchCityDictionary() []address.City { return []address.City{ {Name: "Новосибирск", Aliases: []string{"нск"}}, {Name: "Бердск"}, } } func cityEntries(t *testing.T, parser *address.Parser, rows ...[2]string) []address.Entry { t.Helper() entries := make([]address.Entry, 0, len(rows)) for _, row := range rows { p := parser.ParseInCity(row[1], row[0]) entries = append(entries, address.Entry{ ID: row[0] + "/" + row[1], Address: row[1], City: p.City, CityKey: p.CityKey, Ordinal: p.Ordinal, NameKey: p.NameKey, NameDisplay: p.NameDisplay, StreetType: p.StreetType, StreetKey: p.StreetKey, House: p.House, HouseFrac: p.HouseFrac, HouseLetter: p.HouseLetter, Building: p.Building, BuildingFrac: p.BuildingFrac, BuildingLetter: p.BuildingLetter, Structure: p.Structure, }) } return entries } func TestIndexKeepsCitiesApart(t *testing.T) { parser := address.NewParser(address.NewDictionary(matchCityDictionary()), "") entries := cityEntries(t, parser, [2]string{"Новосибирск", "Ленина улица 5"}, [2]string{"Бердск", "Ленина улица 5"}, ) resolver := NewResolverWithOptions( NewIndex("test", entries, DefaultOptions()), nil, ResolverOptions{Parser: parser}, ) nsk := resolver.ResolveInCity(t.Context(), "Ленина 5", "Новосибирск") if nsk.Status != address.StatusFound || nsk.City != "Новосибирск" { t.Fatalf("Новосибирск: %+v", nsk) } berdsk := resolver.ResolveInCity(t.Context(), "Ленина 5", "Бердск") if berdsk.Status != address.StatusFound || berdsk.City != "Бердск" { t.Fatalf("Бердск: %+v", berdsk) } // Without a city the resolver must not pick one: both streets are exact // matches in different cities. res := resolver.Resolve(t.Context(), "Ленина 5") if res.Status != address.StatusAmbiguous { t.Fatalf("cityless status = %q, want ambiguous (candidates: %+v)", res.Status, res.Candidates) } if len(res.Candidates) != 2 { t.Fatalf("candidates = %+v, want one per city", res.Candidates) } if res.City != "" { t.Fatalf("ambiguous cityless result must not report a city, got %q", res.City) } } func TestCitylessQueryReportsTheOnlyCity(t *testing.T) { parser := address.NewParser(address.NewDictionary(matchCityDictionary()), "") entries := cityEntries(t, parser, [2]string{"Новосибирск", "Ленина улица 5"}) resolver := NewResolverWithOptions( NewIndex("test", entries, DefaultOptions()), nil, ResolverOptions{Parser: parser}, ) res := resolver.Resolve(t.Context(), "Ленина 5") if res.Status != address.StatusFound || res.City != "Новосибирск" { t.Fatalf("result = %+v", res) } } func TestStructureIsPartOfHouseIdentity(t *testing.T) { ix := buildIndex(t, "Станционная улица 1", "Станционная улица 1 стр. 2", ) found := ix.Resolve(address.Parse("станционная 1 стр. 2")) if found.Status != address.StatusFound || found.Address != "Станционная улица 1 стр. 2" { t.Fatalf("exact structure: %+v", found) } // A structure that does not exist must never resolve to another building. wrong := ix.Resolve(address.Parse("станционная 1 стр. 3")) if wrong.Status == address.StatusFound { t.Fatalf("wrong structure resolved to %q", wrong.Address) } if wrong.Status != address.StatusAmbiguous { t.Fatalf("wrong structure status = %q, want ambiguous", wrong.Status) } base := ix.Resolve(address.Parse("станционная 1")) if base.Status != address.StatusFound || base.Address != "Станционная улица 1" { t.Fatalf("base house: %+v", base) } } func TestIndexOrderIsDeterministic(t *testing.T) { addresses := []string{ "Станционная улица 1/1", "Станционная улица 2/1", "Станционная улица 3/1 к1", "Станционная улица 3/1 к2", "Искитимская улица 5", } forward := buildEntries(t, addresses...) backward := buildEntries(t, reverse(addresses)...) a := NewIndex("v", forward, DefaultOptions()) b := NewIndex("v", backward, DefaultOptions()) for _, q := range []string{"станционная 3/1", "станционная 1/2", "искитимсая 5"} { ra := a.Resolve(address.Parse(q)) rb := b.Resolve(address.Parse(q)) if !reflect.DeepEqual(ra, rb) { t.Fatalf("%q: results differ\n a=%+v\n b=%+v", q, ra, rb) } } } func TestLongTokenDoesNotMatch(t *testing.T) { ix := buildIndex(t, "Станционная улица 1") res := ix.Resolve(address.Parse(strings.Repeat("а", 4000))) if res.Status != address.StatusNotFound { t.Fatalf("status = %q, want not_found", res.Status) } } func buildEntries(t *testing.T, addresses ...string) []address.Entry { t.Helper() entries := make([]address.Entry, 0, len(addresses)) for _, a := range addresses { p := address.Parse(a) entries = append(entries, address.Entry{ ID: a, Address: a, Ordinal: p.Ordinal, NameKey: p.NameKey, NameDisplay: p.NameDisplay, StreetType: p.StreetType, StreetKey: p.StreetKey, House: p.House, HouseFrac: p.HouseFrac, HouseLetter: p.HouseLetter, Building: p.Building, BuildingFrac: p.BuildingFrac, BuildingLetter: p.BuildingLetter, Structure: p.Structure, }) } return entries } func reverse(in []string) []string { out := make([]string, len(in)) for i, s := range in { out[len(in)-1-i] = s } return out }