1package enrich23import (4 "encoding/json"5 "strings"6 "testing"78 "github.com/FelineStateMachine/atlas/format/semconv"9)1011// unregistered is a key in the conventions namespace that the registry does not12// know. It is assembled rather than written out because a literal atlas.* key13// outside the registry is itself a lint failure -- which is the rule this14// constant exists to test the other side of.15var unregistered = semconv.Namespace + "invented.key"1617// A volume to try things on: one world, one point collection, one feature that18// says nothing about itself.19func sample() *Volume {20 return &Volume{21 Slug: "tunic",22 Title: "TUNIC",23 Source: Source{Name: "mapgenie", Label: "MapGenie"},24 Worlds: []World{{25 Slug: "overworld",26 Title: "Overworld",27 CapturedAt: "2026-07-30T03:57:41.529Z",28 Grid: Grid{SourceZoom: 5, FirstTile: 0, TileSize: 256, Size: 8192},29 Lenses: []Lens{{Name: "Default", TileSet: "tunic/overworld"}},30 Collections: []Collection{{31 ID: 7,32 Title: "Chests",33 Kind: KindPoint,34 Icon: "chest",35 Visible: true,36 Features: []Feature{{37 ID: 1,38 Title: "A chest",39 At: &Position{Lat: 1, Lng: 2},40 }},41 }},42 }},43 }44}4546func TestApplyFillsWhatIsEmptyAndRefusesTheRest(t *testing.T) {47 described := sample()48 described.Worlds[0].Collections[0].Features[0].Description = "already said"49 positioned := sample()50 positioned.Worlds[0].Collections[0].Features[0].Attrs = map[string]string{semconv.KeyGeoLat: "1.5"}51 arted := sample()52 arted.Worlds[0].Collections[0].Icon = "own-artwork"5354 cases := []struct {55 what string56 volume *Volume57 op Op58 refuse string59 }{60 {61 what: "prose lands on a feature that has none",62 volume: sample(),63 op: Op{Kind: OpSetProse, World: "overworld", Feature: 1, Value: "a chest"},64 },65 {66 what: "prose does not overwrite a source",67 volume: described,68 op: Op{Kind: OpSetProse, World: "overworld", Feature: 1, Value: "something else"},69 refuse: "rewrites nothing",70 },71 {72 what: "an attribute lands where the key is free",73 volume: sample(),74 op: Op{Kind: OpSetAttr, World: "overworld", Feature: 1,75 Entity: semconv.EntityFeature, Key: semconv.KeyGeoLat, Value: "1.5"},76 },77 {78 what: "an attribute does not overwrite a different value",79 volume: positioned,80 op: Op{Kind: OpSetAttr, World: "overworld", Feature: 1,81 Entity: semconv.EntityFeature, Key: semconv.KeyGeoLat, Value: "9.9"},82 refuse: "already says",83 },84 {85 what: "an attribute that repeats what is there is not a rewrite",86 volume: positioned,87 op: Op{Kind: OpSetAttr, World: "overworld", Feature: 1,88 Entity: semconv.EntityFeature, Key: semconv.KeyGeoLat, Value: "1.5"},89 },90 {91 what: "an unregistered attribute is refused",92 volume: sample(),93 op: Op{Kind: OpSetAttr, World: "overworld", Feature: 1,94 Entity: semconv.EntityFeature, Key: unregistered, Value: "x"},95 refuse: "not registered",96 },97 {98 what: "an attribute on the wrong entity is refused",99 volume: sample(),100 op: Op{Kind: OpSetAttr, World: "overworld", Collection: 7,101 Entity: semconv.EntityCollection, Key: semconv.KeyGeoLat, Value: "1.5"},102 refuse: "attaches to a feature",103 },104 {105 what: "a value outside its vocabulary is refused",106 volume: sample(),107 op: Op{Kind: OpSetAttr, World: "overworld", Collection: 7,108 Entity: semconv.EntityCollection, Key: semconv.KeyRenderAs, Value: "hologram"},109 refuse: "is not one of",110 },111 {112 what: "a feature joins a collection that exists",113 volume: sample(),114 op: Op{Kind: OpAddFeature, World: "overworld", Collection: 7,115 NewFeature: &Feature{ID: 2, Title: "Another chest", At: &Position{}}},116 },117 {118 what: "a feature may not reuse an identifier",119 volume: sample(),120 op: Op{Kind: OpAddFeature, World: "overworld", Collection: 7,121 NewFeature: &Feature{ID: 1, Title: "A different chest", At: &Position{}}},122 refuse: "already spoken for",123 },124 {125 what: "a feature needs a collection that exists",126 volume: sample(),127 op: Op{Kind: OpAddFeature, World: "overworld", Collection: 404,128 NewFeature: &Feature{ID: 2, At: &Position{}}},129 refuse: "no collection",130 },131 {132 what: "a world may not be added twice",133 volume: sample(),134 op: Op{Kind: OpAddWorld, NewWorld: &World{Slug: "overworld"}},135 refuse: "already pictures",136 },137 {138 what: "a lens attaches",139 volume: sample(),140 op: Op{Kind: OpSetLens, World: "overworld", Lens: &Lens{Name: "Aligned", TileSet: "other"}},141 },142 {143 what: "a lens updates in place when it is the same picture",144 volume: sample(),145 op: Op{Kind: OpSetLens, World: "overworld",146 Lens: &Lens{Name: "Default", TileSet: "tunic/overworld", Stamp: "abc"}},147 },148 {149 what: "a lens may not be repointed at another tile set",150 volume: sample(),151 op: Op{Kind: OpSetLens, World: "overworld",152 Lens: &Lens{Name: "Default", TileSet: "somewhere/else"}},153 refuse: "does not repoint",154 },155 {156 what: "artwork lands on a collection that has none",157 volume: sample(),158 op: Op{Kind: OpSetIcon, World: "overworld", Collection: 7, Key: "std--maki-monument"},159 refuse: "already names artwork",160 },161 {162 what: "artwork does not displace a source's own",163 volume: arted,164 op: Op{Kind: OpSetIcon, World: "overworld", Collection: 7, Key: "std--maki-monument"},165 refuse: "already names artwork",166 },167 {168 what: "an operation about a world that is not there is refused",169 volume: sample(),170 op: Op{Kind: OpSetProse, World: "elsewhere", Feature: 1, Value: "x"},171 refuse: "pictures no world",172 },173 {174 what: "an operation nobody defined is refused",175 volume: sample(),176 op: Op{Kind: "delete-everything", World: "overworld"},177 refuse: "no such operation",178 },179 }180181 for _, c := range cases {182 t.Run(c.what, func(t *testing.T) {183 err := Apply(c.volume, Contribution{Enricher: "test", Volume: "tunic", Ops: []Op{c.op}})184 switch {185 case c.refuse == "" && err != nil:186 t.Fatalf("refused: %v", err)187 case c.refuse != "" && err == nil:188 t.Fatal("accepted")189 case c.refuse != "" && !strings.Contains(err.Error(), c.refuse):190 t.Fatalf("refused with %q, expected something about %q", err, c.refuse)191 }192 })193 }194}195196// The one case above that looks like a contradiction: a collection whose197// artwork key is set by its source refuses a standard icon, and one whose key198// is free takes it. The sample's collection names "chest", so both rows refuse;199// this is the accepting half.200func TestArtworkLandsWhereThereIsNone(t *testing.T) {201 volume := sample()202 volume.Worlds[0].Collections[0].Icon = ""203 err := Apply(volume, Contribution{Volume: "tunic", Ops: []Op{{204 Kind: OpSetIcon, World: "overworld", Collection: 7,205 Key: "std--maki-monument", Value: "std--maki-monument.svg",206 }}})207 if err != nil {208 t.Fatal(err)209 }210 collection := volume.Worlds[0].Collections[0]211 if collection.Icon != "std--maki-monument" || collection.IconAsset != "std--maki-monument.svg" {212 t.Errorf("collection carries %q/%q", collection.Icon, collection.IconAsset)213 }214}215216func TestContributionRoundTripsThroughItsCanonicalForm(t *testing.T) {217 contribution := Contribution{218 Enricher: "merge",219 Volume: "tunic",220 Ops: []Op{221 {Kind: OpSetProse, World: "overworld", Feature: 1, Value: "a chest, & a key"},222 {Kind: OpSetAttr, World: "overworld", Feature: 1,223 Entity: semconv.EntityFeature, Key: semconv.KeyGeoLat, Value: "1.5"},224 {Kind: OpLedger, World: "overworld", Account: &Account{225 Source: "IGN Wiki", Slug: "ign-wiki",226 DonorFeatures: Counts{Point: 2},227 Matched: []MatchedPair{{Donor: 9, Winner: 1, DistancePx: 12}},228 Added: 1,229 }},230 },231 }232233 data, err := contribution.Marshal()234 if err != nil {235 t.Fatal(err)236 }237 // The canonical form carries what it says it carries: an ampersand in238 // somebody's prose is an ampersand.239 if !strings.Contains(string(data), "a chest, & a key") {240 t.Errorf("the canonical form escaped its own content:\n%s", data)241 }242 read, err := UnmarshalContribution(data)243 if err != nil {244 t.Fatal(err)245 }246 again, err := read.Marshal()247 if err != nil {248 t.Fatal(err)249 }250 if string(again) != string(data) {251 t.Errorf("a round trip changed the bytes:\n%s\n%s", data, again)252 }253254 // Applying the read-back form does what applying the value does. The255 // in-memory interface is a convenience over the serialized contract, so the256 // two have to agree.257 fromValue, fromBytes := sample(), sample()258 if err := Apply(fromValue, contribution); err != nil {259 t.Fatal(err)260 }261 if err := Apply(fromBytes, read); err != nil {262 t.Fatal(err)263 }264 if a, b := marshalVolume(t, fromValue), marshalVolume(t, fromBytes); a != b {265 t.Errorf("the value and its serialization applied differently:\n%s\n%s", a, b)266 }267268 digest, err := contribution.Digest()269 if err != nil {270 t.Fatal(err)271 }272 twice, err := read.Digest()273 if err != nil {274 t.Fatal(err)275 }276 if digest != twice || len(digest) != 64 {277 t.Errorf("digest %q then %q", digest, twice)278 }279}280281func marshalVolume(t *testing.T, v *Volume) string {282 t.Helper()283 data, err := json.Marshal(v)284 if err != nil {285 t.Fatal(err)286 }287 return string(data)288}289290// stub is an enricher a test can point in any direction.291type stub struct {292 name string293 declares []string294 ops []Op295 err error296}297298func (s stub) Name() string { return s.name }299func (s stub) Declares() []string { return s.declares }300func (s stub) Enrich(v *Volume, ctx Context) (Contribution, error) {301 if s.err != nil {302 return Contribution{}, s.err303 }304 return Contribution{Ops: s.ops}, nil305}306307func TestQueueHoldsCurationAndTheBinaryToEachOther(t *testing.T) {308 offered := []Enricher{stub{name: "merge"}, stub{name: "stdicons"}}309 cases := []struct {310 what string311 order []string312 refuse string313 }{314 {what: "the declared order runs", order: []string{"stdicons", "merge"}},315 {what: "a name nobody answers", order: []string{"merge", "invented"}, refuse: "no enricher answers"},316 {what: "a name twice", order: []string{"merge", "merge", "stdicons"}, refuse: "twice"},317 {what: "an enricher nobody queued", order: []string{"merge"}, refuse: "runs in no declared order"},318 }319 for _, c := range cases {320 t.Run(c.what, func(t *testing.T) {321 queue, err := Queue(c.order, offered)322 if c.refuse == "" {323 if err != nil {324 t.Fatalf("refused: %v", err)325 }326 for at, name := range c.order {327 if queue[at].Name() != name {328 t.Errorf("position %d runs %s, curation says %s", at, queue[at].Name(), name)329 }330 }331 return332 }333 if err == nil || !strings.Contains(err.Error(), c.refuse) {334 t.Fatalf("error %v, expected something about %q", err, c.refuse)335 }336 })337 }338}339340func TestRunHoldsAnEnricherToWhatItDeclares(t *testing.T) {341 writesGeo := []Op{{Kind: OpSetAttr, World: "overworld", Feature: 1,342 Entity: semconv.EntityFeature, Key: semconv.KeyGeoLat, Value: "1.5"}}343344 if _, err := Run(sample(), []Enricher{stub{name: "sneaky", ops: writesGeo}}, Context{}); err == nil {345 t.Error("an enricher wrote an attribute it never declared")346 }347 if _, err := Run(sample(), []Enricher{348 stub{name: "honest", declares: []string{semconv.KeyGeoLat}, ops: writesGeo},349 }, Context{}); err != nil {350 t.Errorf("an enricher was refused what it declared: %v", err)351 }352 if _, err := Run(sample(), []Enricher{353 stub{name: "confused", declares: []string{unregistered}},354 }, Context{}); err == nil {355 t.Error("an enricher declared a key the registry does not know")356 }357}358359func TestRunReportsNoChangeAsNoChange(t *testing.T) {360 // The driver opens every world's origin account before the queue runs, so361 // the volume a quiet run is compared against has its own account open too.362 opened := sample()363 OpenOrigin(opened)364 before := marshalVolume(t, opened)365 volume := sample()366 result, err := Run(volume, []Enricher{stub{name: "quiet"}}, Context{})367 if err != nil {368 t.Fatal(err)369 }370 if result.Changed {371 t.Error("an enricher that said nothing reported a change")372 }373 if len(result.Contributions) != 1 || !result.Contributions[0].Empty() {374 t.Error("a run does not report what each enricher had to say")375 }376 if after := marshalVolume(t, volume); after != before {377 t.Errorf("the volume moved:\n%s\n%s", before, after)378 }379}380381func TestRunLetsLaterEnrichersSeeEarlierOnes(t *testing.T) {382 adds := stub{name: "adds", ops: []Op{{Kind: OpAddCollection, World: "overworld",383 NewCollection: &Collection{ID: 8, Title: "Doors", Kind: KindPoint}}}}384 // The second enricher can only succeed if the first one's collection is385 // already there, which is the whole meaning of an ordered queue. It ledgers386 // what it adds, because the world gate counts what every account claims.387 fills := stub{name: "fills", ops: []Op{388 {Kind: OpAddFeature, World: "overworld", Collection: 8,389 NewFeature: &Feature{ID: 2, Title: "A door", At: &Position{}}},390 {Kind: OpLedger, World: "overworld", Account: &Account{391 Source: "Doors", DonorFeatures: Counts{Point: 1}, Added: 1}},392 }}393394 volume := sample()395 if _, err := Run(volume, []Enricher{adds, fills}, Context{}); err != nil {396 t.Fatalf("in order: %v", err)397 }398 if _, err := Run(sample(), []Enricher{fills, adds}, Context{}); err == nil {399 t.Error("out of order, the second enricher filled a collection that did not exist yet")400 }401}402403func TestGateAuditsAnAccount(t *testing.T) {404 cases := []struct {405 what string406 account Account407 refuse string408 }{409 {410 what: "everything offered is accounted for",411 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 3},412 Matched: []MatchedPair{{Donor: 1, Winner: 10}}, Added: 1,413 Held: []HeldItem{{Donor: 3, Reason: "unsure"}}},414 },415 {416 what: "a feature nobody accounted for",417 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 3},418 Matched: []MatchedPair{{Donor: 1, Winner: 10}}},419 refuse: "accounts for 1 of 3",420 },421 {422 what: "shapes are held, every one",423 account: Account{Source: "IGN", DonorFeatures: Counts{Area: 2},424 Held: []HeldItem{{Donor: 1, Reason: HeldShapeReason}}},425 refuse: "holds 1 shape features of the 2",426 },427 {428 what: "one place matched twice",429 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 2},430 Matched: []MatchedPair{{Donor: 1, Winner: 10}, {Donor: 2, Winner: 10}}},431 refuse: "a place is one place",432 },433 {434 what: "a take of a key no feature may carry",435 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 1},436 Matched: []MatchedPair{{Donor: 1, Winner: 10, Took: []string{semconv.KeyRenderAs}}}},437 refuse: "which no feature may carry",438 },439 {440 what: "an enriched flag its takes do not support",441 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 1},442 Matched: []MatchedPair{{Donor: 1, Winner: 10, Enriched: true}}},443 refuse: "says enriched=true",444 },445 {446 what: "added shapes nobody can produce",447 account: Account{Source: "IGN", DonorFeatures: Counts{Point: 1}, Added: 1, AddedShapes: 2},448 refuse: "no shape feature merges yet",449 },450 {451 what: "an origin account that claims a contribution",452 account: Account{Source: "MapGenie", Origin: true, Added: 3},453 refuse: "only says what the world arrived with",454 },455 }456 for _, c := range cases {457 t.Run(c.what, func(t *testing.T) {458 err := GateAccount(c.account)459 switch {460 case c.refuse == "" && err != nil:461 t.Fatalf("refused: %v", err)462 case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):463 t.Fatalf("error %v, expected something about %q", err, c.refuse)464 }465 })466 }467}468469func TestGateWorldHoldsTheTallyToTheLedger(t *testing.T) {470 volume := sample()471 OpenOrigin(volume)472 if err := GateWorld(&volume.Worlds[0]); err != nil {473 t.Fatalf("a world with only its origin account: %v", err)474 }475476 // A feature added without a ledger line to answer for it.477 volume.Worlds[0].Collections[0].Features = append(volume.Worlds[0].Collections[0].Features,478 Feature{ID: 2, Title: "Unaccounted", At: &Position{}})479 if err := GateWorld(&volume.Worlds[0]); err == nil {480 t.Error("a world held a feature no account claims")481 }482483 // Two features under one identifier.484 doubled := sample()485 OpenOrigin(doubled)486 doubled.Worlds[0].Collections = append(doubled.Worlds[0].Collections, Collection{487 ID: 8, Kind: KindArea, Title: "Zones",488 Features: []Feature{{ID: 1, Title: "A zone"}},489 })490 if err := GateWorld(&doubled.Worlds[0]); err == nil {491 t.Error("a point and an area shared an identifier")492 }493}494495func TestOpenOriginSaysWhatTheWorldArrivedWith(t *testing.T) {496 volume := sample()497 OpenOrigin(volume)498 account := volume.Worlds[0].Ledger[0]499 if !account.Origin || account.Slug != "mapgenie" || account.DonorFeatures.Point != 1 {500 t.Errorf("origin account is %+v", account)501 }502 // Opening a ledger twice does not rewrite it.503 volume.Worlds[0].Collections[0].Features = append(volume.Worlds[0].Collections[0].Features,504 Feature{ID: 2, At: &Position{}})505 OpenOrigin(volume)506 if volume.Worlds[0].Ledger[0].DonorFeatures.Point != 1 {507 t.Error("opening an account that was already open rewrote it")508 }509}510511func TestServingIsTheNewestCapture(t *testing.T) {512 older := &Volume{Worlds: []World{{CapturedAt: "2026-08-01T04:03:49Z"}}}513 newer := &Volume{Worlds: []World{{CapturedAt: "2026-08-01T05:09:07Z"}}}514 if got := Serving([]*Volume{older, newer}); got != 1 {515 t.Errorf("served reading %d, expected the newer one", got)516 }517 if got := Serving([]*Volume{newer, older}); got != 0 {518 t.Errorf("served reading %d, expected the newer one", got)519 }520 if got := Serving(nil); got != -1 {521 t.Errorf("served %d readings of nothing", got)522 }523}524525func TestBuildRevisionOutranksThePlainBuild(t *testing.T) {526 for _, generate := range []int{1, 9, 99} {527 enriched, err := BuildRevision(generate)528 if err != nil {529 t.Fatalf("generate revision %d: %v", generate, err)530 }531 if enriched <= generate {532 t.Errorf("an enriched build of generate revision %d carries %d, which does not outrank it",533 generate, enriched)534 }535 policy, ok := Enriched(enriched)536 if !ok || policy != PolicyRevision {537 t.Errorf("revision %d reads as enriched=%t policy %d", enriched, ok, policy)538 }539 if _, ok := Enriched(generate); ok && generate < RevisionSpan {540 t.Errorf("a plain build at revision %d reads as enriched", generate)541 }542 }543 if _, err := BuildRevision(RevisionSpan); err == nil {544 t.Error("a generate revision that does not fit the span was packed anyway")545 }546 // Two enrich policies of one generate revision order by the enrich policy,547 // which is the whole point of the packing.548 first, _ := BuildRevision(9)549 if first != PolicyRevision*RevisionSpan+9 {550 t.Errorf("revision packing is %d", first)551 }552}553554func TestGeometryReadsRingsAndLines(t *testing.T) {555 rings := Geometry{Type: "MultiPolygon",556 Coordinates: json.RawMessage(`[[[[0,0],[2,0],[2,2],[0,2],[0,0]]]]`)}557 if got := rings.Rings(); len(got) != 1 || len(got[0]) != 1 || len(got[0][0]) != 5 {558 t.Errorf("rings read as %v", got)559 }560 if got := rings.Lines(); got != nil {561 t.Errorf("a polygon read as lines: %v", got)562 }563 lines := Geometry{Type: "MultiLineString", Coordinates: json.RawMessage(`[[[0,0],[1,1]]]`)}564 if got := lines.Lines(); len(got) != 1 || len(got[0]) != 2 {565 t.Errorf("lines read as %v", got)566 }567 if got := lines.Positions(); len(got) != 2 || got[1] != [2]float64{1, 1} {568 t.Errorf("positions read as %v", got)569 }570}571572func TestGridProjectionRoundTrips(t *testing.T) {573 grid := Grid{SourceZoom: 5, FirstTile: 0, TileSize: 256, Size: 8192}574 for _, position := range []Position{{Lat: 0, Lng: 0}, {Lat: 27.05, Lng: -22.5}, {Lat: -60, Lng: 179}} {575 x, y := grid.ProjectX(position.Lng), grid.ProjectY(position.Lat)576 lat, lng := grid.UnprojectLat(y), grid.UnprojectLng(x)577 if diff := lat - position.Lat; diff > 1e-9 || diff < -1e-9 {578 t.Errorf("latitude %v came back as %v", position.Lat, lat)579 }580 if diff := lng - position.Lng; diff > 1e-9 || diff < -1e-9 {581 t.Errorf("longitude %v came back as %v", position.Lng, lng)582 }583 }584 if (Grid{}).Ready() {585 t.Error("a world cut from no window reports that it can measure")586 }587}588589func TestMergeIdentityPrefersTheDeclaredName(t *testing.T) {590 cases := []struct {591 what string592 collection Collection593 want string594 }{595 {"the declared merge identity wins",596 Collection{Attrs: map[string]string{semconv.KeyCollectionKey: "ripperdoc"},597 Key: "clinic", Icon: "ripper-doc"}, "ripperdoc"},598 {"then the source's own key", Collection{Key: "zoning", Icon: "polygon"}, "zoning"},599 {"then the artwork key", Collection{Icon: "chest"}, "chest"},600 {"and otherwise nothing", Collection{}, ""},601 }602 for _, c := range cases {603 t.Run(c.what, func(t *testing.T) {604 if got := MergeIdentity(c.collection); got != c.want {605 t.Errorf("identity %q, want %q", got, c.want)606 }607 })608 }609}