clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / enrich / enrichers / merge / merge_test.go
merge_test.go 19.2K
1package merge23import (4 "fmt"5 "strings"6 "testing"78 "github.com/FelineStateMachine/atlas/format/semconv"9 "github.com/FelineStateMachine/atlas/internal/enrich"10 "github.com/FelineStateMachine/atlas/internal/enrich/curation"11)1213func tables(t *testing.T) enrich.Curation {14 t.Helper()15 loaded, err := curation.Load()16 if err != nil {17 t.Fatal(err)18 }19 return loaded20}2122var grid = enrich.Grid{SourceZoom: 5, FirstTile: 0, TileSize: 256, Size: 8192}2324// place puts a feature at a world position given in world pixels, which is what25// every radius in this lane is measured in.26func place(id int64, title string, x, y float64) enrich.Feature {27 return enrich.Feature{28 ID: id,29 Title: title,30 At: &enrich.Position{Lat: grid.UnprojectLat(y), Lng: grid.UnprojectLng(x)},31 }32}3334// anchorsFor is the calibration both readings share: enough unambiguous names,35// in the same places, that the fit closes as the identity.36func anchorsFor(base int64) []enrich.Feature {37 var out []enrich.Feature38 for index := range 20 {39 out = append(out, place(base+int64(index),40 fmt.Sprintf("anchor %d", index),41 float64(500+index*180), float64(400+(index*137)%2200)))42 }43 return out44}4546// reading builds one source's reading of one volume: the anchors, plus whatever47// else the case wants to say.48func reading(source enrich.Source, captured string, idBase int64, collections ...enrich.Collection) *enrich.Volume {49 world := enrich.World{50 Slug: "night-city",51 Title: "Night City",52 Grid: grid,53 CapturedAt: captured,54 Lenses: []enrich.Lens{{Name: "Default", TileSet: source.Name + "/night-city"}},55 Collections: []enrich.Collection{{56 ID: idBase, Title: "Landmarks", Kind: enrich.KindPoint, Icon: "landmark",57 Features: anchorsFor(idBase * 1000),58 }},59 }60 world.Collections = append(world.Collections, collections...)61 return &enrich.Volume{62 Slug: "cyberpunk-2077", Title: "Cyberpunk 2077", Source: source,63 Worlds: []enrich.World{world},64 }65}6667var (68 piggyback = enrich.Source{Name: "piggyback", Label: "Piggyback"}69 ign = enrich.Source{Name: "ign-wiki", Label: "IGN Wiki"}70)7172// fold runs the merge and hands back the donor's account and the volume it left.73func fold(t *testing.T, serving, donor *enrich.Volume) (*enrich.Volume, enrich.Account, enrich.Contribution) {74 t.Helper()75 enrich.OpenOrigin(serving)76 contribution, err := New().Enrich(serving, enrich.Context{77 Donors: []*enrich.Volume{donor},78 Curation: tables(t),79 })80 if err != nil {81 t.Fatalf("merge: %v", err)82 }83 if err := enrich.Apply(serving, contribution); err != nil {84 t.Fatalf("apply: %v", err)85 }86 if err := enrich.GateWorld(&serving.Worlds[0]); err != nil {87 t.Fatalf("gate: %v", err)88 }89 for _, account := range serving.Worlds[0].Ledger {90 if !account.Origin {91 return serving, account, contribution92 }93 }94 return serving, enrich.Account{}, contribution95}9697func TestResolutionDecidesByNameAndDistance(t *testing.T) {98 cases := []struct {99 what string100 serving enrich.Collection101 donor enrich.Collection102 matched int103 added int104 held int105 rejected int106 reason string107 }{108 {109 what: "the same name where the alignment predicts is the same place",110 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",111 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},112 donor: enrich.Collection{ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",113 Features: []enrich.Feature{place(9101, "Jinguji", 3100, 3000)}},114 matched: 1,115 },116 {117 what: "the same name far beyond the radius is another place bearing it",118 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",119 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},120 donor: enrich.Collection{ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",121 Features: []enrich.Feature{place(9101, "Jinguji", 4000, 3000)}},122 added: 1,123 },124 {125 what: "the stretch between is left undecided",126 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",127 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},128 donor: enrich.Collection{ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",129 Features: []enrich.Feature{place(9101, "Jinguji", 3250, 3000)}},130 held: 1,131 reason: "too far to merge, too near to double",132 },133 {134 what: "one name written inside the other, in a shared collection",135 serving: enrich.Collection{ID: 90, Title: "Apartments", Kind: enrich.KindPoint, Icon: "apartment",136 Features: []enrich.Feature{place(9001, "Northside, Watson Apartment", 3000, 3000)}},137 donor: enrich.Collection{ID: 91, Title: "Apartments", Kind: enrich.KindPoint, Icon: "apartment",138 Features: []enrich.Feature{place(9101, "Northside Apartment", 3080, 3000)}},139 matched: 1,140 },141 {142 what: "a single word inside a longer name says nothing",143 serving: enrich.Collection{ID: 90, Title: "Apartments", Kind: enrich.KindPoint, Icon: "apartment",144 Features: []enrich.Feature{place(9001, "Northside Watson Apartment", 3000, 3000)}},145 donor: enrich.Collection{ID: 91, Title: "Apartments", Kind: enrich.KindPoint, Icon: "apartment",146 Features: []enrich.Feature{place(9101, "Apartment", 3030, 3000)}},147 held: 1,148 reason: "names disagree",149 },150 {151 what: "a nameless neighbour in a shared collection is held, never guessed",152 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",153 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},154 donor: enrich.Collection{ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",155 Features: []enrich.Feature{place(9101, "Kabuki Market", 3020, 3000)}},156 held: 1,157 reason: "in the same category; names disagree",158 },159 {160 what: "something the serving reading has no collection for is simply added",161 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",162 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},163 donor: enrich.Collection{ID: 91, Title: "Graffiti", Kind: enrich.KindPoint, Icon: "graffiti",164 Features: []enrich.Feature{place(9101, "A tarot card", 3020, 3000)}},165 added: 1,166 },167 {168 what: "a feature the alignment puts off the world is refused",169 serving: enrich.Collection{ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",170 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)}},171 donor: enrich.Collection{ID: 91, Title: "Graffiti", Kind: enrich.KindPoint, Icon: "graffiti",172 Features: []enrich.Feature{place(9101, "Somewhere else", -4000, 3000)}},173 rejected: 1,174 reason: outsideReason,175 },176 }177178 for _, c := range cases {179 t.Run(c.what, func(t *testing.T) {180 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1, c.serving)181 donor := reading(ign, "2026-08-01T04:00:00Z", 2, c.donor)182 _, account, _ := fold(t, serving, donor)183184 // The anchors match each other, so the case's own features are what185 // the counts beyond them are about.186 const anchors = 20187 if got := account.MatchedN() - anchors; got != c.matched {188 t.Errorf("matched %d, want %d", got, c.matched)189 }190 if account.Added != c.added {191 t.Errorf("added %d, want %d", account.Added, c.added)192 }193 if account.HeldN() != c.held {194 t.Errorf("held %d, want %d", account.HeldN(), c.held)195 }196 if account.RejectedN() != c.rejected {197 t.Errorf("rejected %d, want %d", account.RejectedN(), c.rejected)198 }199 if c.reason != "" {200 reasons := append(account.Held, account.Rejected...)201 found := false202 for _, item := range reasons {203 if strings.Contains(item.Reason, c.reason) {204 found = true205 }206 }207 if !found {208 t.Errorf("no held or rejected feature says %q; the ledger says %+v", c.reason, reasons)209 }210 }211 })212 }213}214215func TestAPlaceIsOnePlace(t *testing.T) {216 // Two donor features bearing one name, both near one serving feature: the217 // first is the place, and the second has to say why it is not.218 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1, enrich.Collection{219 ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",220 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)},221 })222 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{223 ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",224 Features: []enrich.Feature{225 place(9101, "Jinguji", 3050, 3000),226 place(9102, "Jinguji", 3060, 3000),227 },228 })229 _, account, _ := fold(t, serving, donor)230 if got := account.MatchedN() - 20; got != 1 {231 t.Errorf("matched %d of the two, want 1", got)232 }233 if account.HeldN() != 1 || !strings.Contains(account.Held[0].Reason, "already matched") {234 t.Errorf("the second was not held for the right reason: %+v", account.Held)235 }236}237238func TestAttributePolicyFillsWhatIsEmpty(t *testing.T) {239 served := place(9001, "Jinguji", 3000, 3000)240 served.Attrs = map[string]string{semconv.KeyGeoLat: "10"}241 offered := place(9101, "Jinguji", 3020, 3000)242 offered.Description = "A clothing shop in Japantown."243 offered.Attrs = map[string]string{semconv.KeyGeoLat: "20", semconv.KeyGeoLon: "30"}244245 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1, enrich.Collection{246 ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",247 Features: []enrich.Feature{served},248 })249 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{250 ID: 91, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",251 Features: []enrich.Feature{offered},252 })253 merged, account, _ := fold(t, serving, donor)254255 _, feature := merged.Worlds[0].Feature(9001)256 if feature.Description != offered.Description {257 t.Errorf("the serving feature kept its silence: %q", feature.Description)258 }259 if feature.Attrs[semconv.KeyGeoLat] != "10" {260 t.Errorf("serving-wins was not honoured: latitude is %q", feature.Attrs[semconv.KeyGeoLat])261 }262 if feature.Attrs[semconv.KeyGeoLon] != "30" {263 t.Errorf("donor-fills-empty was not honoured: longitude is %q", feature.Attrs[semconv.KeyGeoLon])264 }265266 var pair enrich.MatchedPair267 for _, held := range account.Matched {268 if held.Donor == 9101 {269 pair = held270 }271 }272 if !pair.Enriched {273 t.Error("the pair does not record that prose was taken")274 }275 took := strings.Join(pair.Took, ",")276 if !strings.Contains(took, semconv.KeyNoteText) || !strings.Contains(took, semconv.KeyGeoLon) {277 t.Errorf("takes recorded as %q", took)278 }279 if strings.Contains(took, semconv.KeyGeoLat) {280 t.Errorf("a take was recorded for an attribute the serving side kept: %q", took)281 }282}283284func TestCollectionsMeetUnderTheirDeclaredIdentity(t *testing.T) {285 // Two readings spell one concept two ways and say so through the merge286 // identity their payloads carry.287 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1, enrich.Collection{288 ID: 90, Title: "Ripperdoc", Kind: enrich.KindPoint, Icon: "ripperdoc",289 Attrs: map[string]string{semconv.KeyCollectionKey: "ripperdoc"},290 Features: []enrich.Feature{place(9001, "Vik", 3000, 3000)},291 })292 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{293 ID: 91, Title: "Ripper Doc", Kind: enrich.KindPoint, Icon: "ripper-doc",294 Attrs: map[string]string{semconv.KeyCollectionKey: "ripperdoc"},295 Features: []enrich.Feature{place(9101, "Cassius Ryder", 5000, 5000)},296 })297 merged, account, _ := fold(t, serving, donor)298299 if account.Added != 1 || account.AdoptedN() != 1 {300 t.Fatalf("added %d, adopted %d", account.Added, account.AdoptedN())301 }302 if account.Adopted[0].Into != "ripperdoc" {303 t.Errorf("adopted into %q", account.Adopted[0].Into)304 }305 if collection, _ := merged.Worlds[0].Feature(9101); collection.ID != 90 {306 t.Errorf("the adopted feature joined collection %d, not the serving one", collection.ID)307 }308 for _, collection := range merged.Worlds[0].Collections {309 if collection.ID == 91 {310 t.Error("a collection was contributed for a concept the serving reading already had")311 }312 }313}314315func TestCollectionsWithNoCounterpartKeepTheirOwn(t *testing.T) {316 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1)317 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{318 ID: 91, Title: "Tarot Graffiti", Kind: enrich.KindPoint, Icon: "tarot",319 Features: []enrich.Feature{place(9101, "The Fool", 3000, 3000)},320 })321 donor.Icons = []enrich.Icon{{Key: "tarot", File: "tarot.svg", Data: []byte("<svg/>")}}322 merged, account, _ := fold(t, serving, donor)323324 if account.Added != 1 || account.AdoptedN() != 0 {325 t.Fatalf("added %d, adopted %d", account.Added, account.AdoptedN())326 }327 var kept *enrich.Collection328 for index := range merged.Worlds[0].Collections {329 if merged.Worlds[0].Collections[index].ID == 91 {330 kept = &merged.Worlds[0].Collections[index]331 }332 }333 if kept == nil {334 t.Fatal("the donor's own collection was not contributed")335 }336 if kept.Group != "IGN Wiki" {337 t.Errorf("the contributed collection files under %q; the legend should say where it came from", kept.Group)338 }339 // The key a collection names its artwork by stays the donor's own: nothing340 // in the serving volume is called "tarot", so there is nothing to341 // displace. The file the bytes land in is tagged all the same, because a342 // contribution may never write into the serving volume's icons directory343 // under a name that volume might itself use.344 if kept.Icon != "tarot" {345 t.Errorf("artwork came across under %q; the donor's own name was free", kept.Icon)346 }347 carried := false348 for _, icon := range merged.Icons {349 if icon.Key == "tarot" && icon.File == "ign-wiki--tarot.svg" {350 carried = true351 }352 }353 if !carried {354 t.Errorf("the artwork itself did not travel: %+v", merged.Icons)355 }356}357358// TestArtworkThatWouldDisplaceTakesTheSourcesName is the other half of that359// rule: a donor whose artwork key is already spoken for by different bytes360// answers to a tagged name instead, so a contributed collection can never361// repoint one the serving reading drew.362func TestArtworkThatWouldDisplaceTakesTheSourcesName(t *testing.T) {363 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1)364 serving.Icons = []enrich.Icon{{Key: "tarot", File: "tarot.png", Data: []byte("serving")}}365 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{366 ID: 91, Title: "Tarot Graffiti", Kind: enrich.KindPoint, Icon: "tarot",367 Features: []enrich.Feature{place(9101, "The Fool", 3000, 3000)},368 })369 donor.Icons = []enrich.Icon{{Key: "tarot", File: "tarot.svg", Data: []byte("<svg/>")}}370 merged, _, _ := fold(t, serving, donor)371372 for _, collection := range merged.Worlds[0].Collections {373 if collection.ID == 91 && collection.Icon != "ign-wiki--tarot" {374 t.Errorf("contributed artwork came across as %q, over the serving volume's own",375 collection.Icon)376 }377 }378 for _, icon := range merged.Icons {379 if icon.Key == "tarot" && string(icon.Data) != "serving" {380 t.Error("the serving volume's own artwork was replaced by the donor's")381 }382 }383}384385func TestAGroundWithNoCounterpartJoinsWhole(t *testing.T) {386 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1)387 donor := reading(ign, "2026-08-01T04:00:00Z", 2)388 // A second ground the serving reading does not picture, with places that389 // resemble nothing it holds.390 donor.Worlds = append(donor.Worlds, enrich.World{391 Slug: "dogtown", Title: "Dogtown", Grid: grid, CapturedAt: "2026-08-01T04:00:00Z",392 Lenses: []enrich.Lens{{Name: "IGN Wiki", TileSet: "ign/dogtown"}},393 Collections: []enrich.Collection{{394 ID: 95, Title: "Stashes", Kind: enrich.KindPoint, Icon: "stash",395 Features: []enrich.Feature{place(9501, "A stash", 1000, 1000)},396 }},397 })398 merged, _, _ := fold(t, serving, donor)399400 dogtown := merged.World("dogtown")401 if dogtown == nil {402 t.Fatal("the ground did not join")403 }404 if len(dogtown.Ledger) != 1 || !dogtown.Ledger[0].Origin || dogtown.Ledger[0].Slug != "ign-wiki" {405 t.Errorf("a ground that joined whole says nothing about where it came from: %+v", dogtown.Ledger)406 }407 if err := enrich.GateWorld(dogtown); err != nil {408 t.Errorf("the contributed ground does not add up: %v", err)409 }410}411412func TestShapesAreHeldOnTheRecord(t *testing.T) {413 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1)414 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{415 ID: 92, Title: "Districts", Kind: enrich.KindArea,416 Features: []enrich.Feature{{ID: 9201, Title: "Watson"}, {ID: 9202, Title: "Westbrook"}},417 })418 _, account, _ := fold(t, serving, donor)419420 if account.DonorFeatures.Area != 2 {421 t.Errorf("the offering counts %d areas", account.DonorFeatures.Area)422 }423 shapes := 0424 for _, held := range account.Held {425 if held.Reason == enrich.HeldShapeReason {426 shapes++427 }428 }429 if shapes != 2 {430 t.Errorf("%d shape features are on the record, want 2", shapes)431 }432}433434func TestARefusedAlignmentIsARefusedMerge(t *testing.T) {435 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1)436 // A reading that shares no name with the serving one cannot be aligned, so437 // nothing may be concluded from it.438 donor := &enrich.Volume{439 Slug: "cyberpunk-2077", Source: ign,440 Worlds: []enrich.World{{441 Slug: "night-city", Grid: grid, CapturedAt: "2026-08-01T04:00:00Z",442 Collections: []enrich.Collection{{443 ID: 93, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",444 Features: []enrich.Feature{place(9301, "Somewhere", 3000, 3000)},445 }},446 }},447 }448 enrich.OpenOrigin(serving)449 contribution, err := New().Enrich(serving, enrich.Context{450 Donors: []*enrich.Volume{donor}, Curation: tables(t),451 })452 if err != nil {453 t.Fatal(err)454 }455 if !contribution.Empty() {456 t.Errorf("a merge that could not be aligned contributed %d operations", len(contribution.Ops))457 }458}459460func TestTheNewestCaptureServes(t *testing.T) {461 serving := reading(piggyback, "2026-08-01T04:00:00Z", 1)462 donor := reading(ign, "2026-08-01T05:00:00Z", 2)463 enrich.OpenOrigin(serving)464 _, err := New().Enrich(serving, enrich.Context{465 Donors: []*enrich.Volume{donor}, Curation: tables(t),466 })467 if err == nil || !strings.Contains(err.Error(), "the newest capture serves") {468 t.Fatalf("error %v", err)469 }470}471472func TestMergeLeavesTheVolumeAloneUntilItIsApplied(t *testing.T) {473 serving := reading(piggyback, "2026-08-01T05:00:00Z", 1, enrich.Collection{474 ID: 90, Title: "Shops", Kind: enrich.KindPoint, Icon: "shop",475 Features: []enrich.Feature{place(9001, "Jinguji", 3000, 3000)},476 })477 donor := reading(ign, "2026-08-01T04:00:00Z", 2, enrich.Collection{478 ID: 91, Title: "Graffiti", Kind: enrich.KindPoint, Icon: "graffiti",479 Features: []enrich.Feature{place(9101, "A tarot card", 6000, 6000)},480 })481 enrich.OpenOrigin(serving)482 before := len(serving.Worlds[0].Collections)483 contribution, err := New().Enrich(serving, enrich.Context{484 Donors: []*enrich.Volume{donor}, Curation: tables(t),485 })486 if err != nil {487 t.Fatal(err)488 }489 if len(serving.Worlds[0].Collections) != before || len(serving.Worlds[0].Ledger) != 1 {490 t.Error("the merge changed the volume before its contribution was applied")491 }492 if contribution.Empty() {493 t.Fatal("nothing was contributed")494 }495 // And applying the contribution twice is refused rather than doubling496 // anything: identifiers are claimed once.497 if err := enrich.Apply(serving, contribution); err != nil {498 t.Fatal(err)499 }500 if err := enrich.Apply(serving, contribution); err == nil {501 t.Error("a contribution applied twice was accepted")502 }503}504505func TestSlugifyFoldsALabelIntoAName(t *testing.T) {506 cases := []struct{ in, want string }{507 {"IGN Wiki", "ign-wiki"},508 {"MapGenie", "mapgenie"},509 {"ArcGIS Open Data", "arcgis-open-data"},510 {" Trailing ", "trailing"},511 {"", ""},512 }513 for _, c := range cases {514 if got := slugify(c.in); got != c.want {515 t.Errorf("%q folded to %q, want %q", c.in, got, c.want)516 }517 }518}