- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
199 lines
7.1 KiB
Go
199 lines
7.1 KiB
Go
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
|
||
}
|