refactor!: make addrkit a multi-city address normalizer
- 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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package address
|
||||
|
||||
import "testing"
|
||||
|
||||
func testDictionary() *Dictionary {
|
||||
return NewDictionary([]City{
|
||||
{Name: "Новосибирск", Aliases: []string{"нск", "новосиб", "новосибирская область", "нсо"}},
|
||||
{Name: "Бердск"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseCityFromAliases(t *testing.T) {
|
||||
parser := NewParser(testDictionary(), "")
|
||||
|
||||
tests := []struct {
|
||||
raw string
|
||||
city string
|
||||
nameKey string
|
||||
}{
|
||||
{"нск, 2-я Станционная улица 1", "Новосибирск", "станционная"},
|
||||
{"НСК Ленина 5", "Новосибирск", "ленина"},
|
||||
{"Новосибирская область, Станционная 1", "Новосибирск", "станционная"},
|
||||
{"Бердск, Ленина 5", "Бердск", "ленина"},
|
||||
{"г. Новосибирск, ул. Ленина 5", "Новосибирск", "ленина"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parser.Parse(tt.raw)
|
||||
if got.City != tt.city {
|
||||
t.Errorf("%q: City = %q, want %q", tt.raw, got.City, tt.city)
|
||||
}
|
||||
if got.NameKey != tt.nameKey {
|
||||
t.Errorf("%q: NameKey = %q, want %q", tt.raw, got.NameKey, tt.nameKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCityIsPartOfStreetKey(t *testing.T) {
|
||||
parser := NewParser(testDictionary(), "")
|
||||
|
||||
nsk := parser.Parse("Новосибирск, Ленина 5")
|
||||
berdsk := parser.Parse("Бердск, Ленина 5")
|
||||
if nsk.StreetKey == berdsk.StreetKey {
|
||||
t.Fatalf("same street key for different cities: %q", nsk.StreetKey)
|
||||
}
|
||||
if nsk.CityKey != "новосибирск" || berdsk.CityKey != "бердск" {
|
||||
t.Fatalf("city keys = %q / %q", nsk.CityKey, berdsk.CityKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultCity(t *testing.T) {
|
||||
parser := NewParser(testDictionary(), "Новосибирск")
|
||||
got := parser.Parse("Ленина 5")
|
||||
if got.City != "Новосибирск" || got.CityKey != "новосибирск" {
|
||||
t.Fatalf("City = %q / %q", got.City, got.CityKey)
|
||||
}
|
||||
|
||||
// The default city is also recognised when the user spells it out, even
|
||||
// if the dictionary contains only aliases.
|
||||
explicit := NewParser(DefaultDictionary(), "Новосибирск").Parse("г. Новосибирск, Ленина 5")
|
||||
if explicit.City != "Новосибирск" || explicit.NameKey != "ленина" {
|
||||
t.Fatalf("explicit default city: %+v", explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInCityOverridesDetectedCity(t *testing.T) {
|
||||
parser := NewParser(testDictionary(), "")
|
||||
got := parser.ParseInCity("Бердск, Ленина 5", "Новосибирск")
|
||||
if got.City != "Новосибирск" {
|
||||
t.Fatalf("City = %q, want forced Новосибирск", got.City)
|
||||
}
|
||||
if got.NameKey != "ленина" {
|
||||
t.Fatalf("NameKey = %q", got.NameKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithoutCityKeepsCityKeyEmpty(t *testing.T) {
|
||||
parser := NewParser(testDictionary(), "")
|
||||
got := parser.Parse("Ленина 5")
|
||||
if got.City != "" || got.CityKey != "" {
|
||||
t.Fatalf("City = %q / %q, want empty", got.City, got.CityKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTextStillLeavesUnknownCityTokens(t *testing.T) {
|
||||
// A city that is not in the dictionary must not be silently dropped: the
|
||||
// resolver should fail rather than guess another city.
|
||||
parser := NewParser(testDictionary(), "")
|
||||
got := parser.Parse("Томск, Ленина 5")
|
||||
if got.City != "" {
|
||||
t.Fatalf("City = %q, want empty for unknown city", got.City)
|
||||
}
|
||||
if got.NameKey == "ленина" {
|
||||
t.Fatalf("unknown city token disappeared from the street name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCity(t *testing.T) {
|
||||
dict := testDictionary()
|
||||
cases := []struct {
|
||||
query string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"нск", "Новосибирск", true},
|
||||
{"г. Новосибирск", "Новосибирск", true},
|
||||
{"Новосибирская область", "Новосибирск", true},
|
||||
{"Бердск", "Бердск", true},
|
||||
{"Бердск, Ленина 5", "", false},
|
||||
{"Томск", "", false},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
got, ok := dict.MatchCity(tt.query)
|
||||
if ok != tt.ok || got.Name != tt.want {
|
||||
t.Errorf("MatchCity(%q) = %+v, %v; want %q, %v", tt.query, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
"",
|
||||
"2-я Станционная улица 1/1 стр.",
|
||||
"нск, Ленина 5",
|
||||
"Бердск, Ленина 5 к2",
|
||||
"г. Новосибирск, ул. Кошурникова, д. 11/1 кв. 5",
|
||||
"\x00\xff",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
parser := NewParser(testDictionary(), "")
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
first := parser.Parse(raw)
|
||||
second := parser.Parse(raw)
|
||||
if first.StreetKey != second.StreetKey {
|
||||
t.Fatalf("non-deterministic street key for %q: %q vs %q", raw, first.StreetKey, second.StreetKey)
|
||||
}
|
||||
if NormalizeText(raw) != NormalizeText(NormalizeText(raw)) {
|
||||
t.Fatalf("NormalizeText is not idempotent for %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user