clone

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

ledger: features are first-class citizens

Dami committed 13e21ab
The merge ledger spoke only of pins: donorPins counted the offering, matched
and added and held accounted for it, and a donor's paths and areas fell out
of the account entirely -- mergeWorld skipped every shape collection without
a word. The account now covers every kind. Each mergedSource carries
donorFeatures, the donor's whole offering counted per kind, with donorPins
kept as the older spelling of its point count until flag day; addedShapes
holds the ledger's place at zero for the day a shape learns to merge. Origin
accounts fill the counts from the world's own collections, so a composed map
says up front how many points, paths, and areas it opened with.

What cannot merge yet is held on the record instead of dropped: mergeWorld
writes one held line per donor shape feature, title and all, under the one
reason 'shape features do not merge yet'. Matching itself stays point-only
and the alignment still stands on point anchors alone -- nothing about the
resolution changed, only the honesty of its account.

mergeGate audits per kind. The two point spellings must agree, the point
arithmetic must close over matched, added, held points, and rejected, the
held shapes must equal the donor's paths and areas, and the reserved
addedShapes must still be zero. The identifier walk now covers features of
every kind -- shape and point ids share one space -- and the winner's
per-kind counts must equal what its ledger sums to. No source pair produces
a shape-carrying donor today (only arcgis emits regions, and it never merges
into another source), so the held-shapes path is proven by fixture worlds in
tests rather than by the corpus.

internal/measure reads donorFeatures when present and lets donorPins stand
in for bundles from before the field existed; cartograph's volume table and
the maturity report print the donor's shape count beside its pins. The
Westminster bundle regenerates with exactly one payload change: the origin
account gains its donorFeatures line.
commit
13e21abda9e1f03ca8751d2a159e95155454ca0e
parent
d39f89df01c363626d10f6bca4795da4834a5c2f
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.9 of 3, Jev's estimate
9 files changed, +449 -77
Mcmd/cartograph/measure.go1+
Mcmd/cartograph/measure_test.go38++++-
Mcmd/cartograph/ui/volume.tmpl4+-
Minternal/measure/measure.go76+++++++---
Mtools/generate/main.go10+-
Mtools/generate/merge.go142++++++++++++++-----
Atools/generate/merge_test.go225++++++++++++++++++++++++++++++
Mtools/generate/normalize.go26+++-
Mtools/maturity/main.go4+-
M cmd/cartograph/measure.go +1 -0
M cmd/cartograph/measure_test.go +33 -5
@@ -23,6 +23,17 @@ func fixtureMerged() []map[string]any {
2323 "held": []map[string]any{{"d": 14, "t": "Old Well", "why": "name 200px away"}},2424 "rejected": []map[string]any{{"d": 15, "t": "Off World", "why": "outside the world"}},2525 "alignment": "affine over 9 anchors, median 26px",26+ }, {27+ // A ledger that speaks features, the way builds write them now:28+ // donorPins repeats the point count and the shapes ride held.29+ "source": "arcgis-hub",30+ "donorPins": 0,31+ "donorFeatures": map[string]any{"point": 0, "path": 1, "area": 2},32+ "held": []map[string]any{33+ {"d": 21, "t": "North Basin", "why": "shape features do not merge yet"},34+ {"d": 22, "t": "South Basin", "why": "shape features do not merge yet"},35+ {"d": 23, "t": "Mill Race", "why": "shape features do not merge yet"},36+ },2637 }}2738 }2839
@@ -80,14 +91,20 @@ func TestMeasureBundle(t *testing.T) {
8091 t.Errorf("icons: %d of %d; want 1 of 1", measured.IconsCarried, measured.IconsWanted)8192 }8293 83- // The merge account, kept whole rather than counted.84- if len(measured.Merges) != 1 {85- t.Fatalf("read %d merge accounts, want 1", len(measured.Merges))94+ // The merge accounts, kept whole rather than counted.95+ if len(measured.Merges) != 2 {96+ t.Fatalf("read %d merge accounts, want 2", len(measured.Merges))8697 }8798 account := measured.Merges[0]8899 if account.Map != "overworld" || account.Source != "ign-wiki" || account.DonorPins != 5 {89100 t.Errorf("account identity: map %s source %s donors %d", account.Map, account.Source, account.DonorPins)90101 }102+ // An account from before the ledger spoke features: the point count103+ // stands in for the whole offering.104+ if account.DonorFeatures != (featureCounts{Point: 5}) || account.DonorShapesN() != 0 {105+ t.Errorf("fallback features read as %+v (%d shapes), want 5 points standing in",106+ account.DonorFeatures, account.DonorShapesN())107+ }91108 if account.MatchedN() != 2 || account.MedianMatchPx() != 40 {92109 t.Errorf("matched %d at median %dpx, want 2 at 40px", account.MatchedN(), account.MedianMatchPx())93110 }
@@ -98,8 +115,19 @@ func TestMeasureBundle(t *testing.T) {
98115 if account.HeldN() != 1 || account.Held[0].Title != "Old Well" || account.Held[0].Reason != "name 200px away" {99116 t.Errorf("held ledger read wrong: %+v", account.Held)100117 }101- if got := measured.SourcesSeen(); len(got) != 1 || got[0] != "ign-wiki" {102- t.Errorf("sources seen %v, want [ign-wiki]", got)118+ // An account that speaks features: the per-kind counts come through and119+ // the held shapes ride the same ledger the held pins do.120+ shapes := measured.Merges[1]121+ if shapes.DonorFeatures != (featureCounts{Point: 0, Path: 1, Area: 2}) || shapes.DonorShapesN() != 3 {122+ t.Errorf("feature counts read as %+v (%d shapes), want 1 path and 2 areas",123+ shapes.DonorFeatures, shapes.DonorShapesN())124+ }125+ if shapes.DonorPins != 0 || shapes.AddedShapes != 0 || shapes.HeldN() != 3 {126+ t.Errorf("shape account read as %d donor pins, %d added shapes, %d held; want 0, 0, 3",127+ shapes.DonorPins, shapes.AddedShapes, shapes.HeldN())128+ }129+ if got := measured.SourcesSeen(); len(got) != 2 || got[0] != "arcgis-hub" || got[1] != "ign-wiki" {130+ t.Errorf("sources seen %v, want [arcgis-hub ign-wiki]", got)103131 }104132 }105133
M cmd/cartograph/ui/volume.tmpl +2 -2
M internal/measure/measure.go +53 -23
@@ -73,12 +73,26 @@ type MergeAccount struct {
7373 Map string7474 Source string7575 DonorPins int76- Matched []MatchedPair77- Added int78- Adopted []AdoptedPin79- Held []HeldPin80- Rejected []HeldPin81- Alignment string76+ // DonorFeatures is the donor's offering counted per kind. Bundles from77+ // before the ledger spoke features carry only donorPins; for those the78+ // point count stands in and the shapes read zero.79+ DonorFeatures FeatureCounts80+ Matched []MatchedPair81+ Added int82+ // AddedShapes is the ledger's reserved shape-merge count, zero in every83+ // bundle written so far.84+ AddedShapes int85+ Adopted []AdoptedPin86+ Held []HeldPin87+ Rejected []HeldPin88+ Alignment string89+}90+91+// FeatureCounts counts donor features by kind, in the ledger's own words.92+type FeatureCounts struct {93+ Point int `json:"point"`94+ Path int `json:"path"`95+ Area int `json:"area"`8296 }8397 8498 // MatchedPair records one place both sources pin, in the ledger's own words.
@@ -114,14 +128,16 @@ type worldDetail struct {
114128 } `json:"categories"`115129 } `json:"groups"`116130 Merged []struct {117- Source string `json:"source"`118- DonorPins int `json:"donorPins"`119- Matched []MatchedPair `json:"matched"`120- Added int `json:"added"`121- Adopted []AdoptedPin `json:"adopted"`122- Held []HeldPin `json:"held"`123- Rejected []HeldPin `json:"rejected"`124- Alignment string `json:"alignment"`131+ Source string `json:"source"`132+ DonorPins int `json:"donorPins"`133+ DonorFeatures *FeatureCounts `json:"donorFeatures"`134+ Matched []MatchedPair `json:"matched"`135+ Added int `json:"added"`136+ AddedShapes int `json:"addedShapes"`137+ Adopted []AdoptedPin `json:"adopted"`138+ Held []HeldPin `json:"held"`139+ Rejected []HeldPin `json:"rejected"`140+ Alignment string `json:"alignment"`125141 } `json:"merged"`126142 Zones []struct {127143 Features []struct {
@@ -148,6 +164,11 @@ func (m MergeAccount) MedianMatchPx() int {
148164 return distances[len(distances)/2]149165 }150166 167+// DonorShapesN is how many shape features -- paths and areas together --168+// the donor offered; none of them merge yet, so each one shows up again in169+// the held ledger.170+func (m MergeAccount) DonorShapesN() int { return m.DonorFeatures.Path + m.DonorFeatures.Area }171+151172 func (m MergeAccount) MatchedN() int { return len(m.Matched) }152173 func (m MergeAccount) AdoptedN() int { return len(m.Adopted) }153174 func (m MergeAccount) HeldN() int { return len(m.Held) }
@@ -317,16 +338,25 @@ func MeasureBundle(path string) (*Build, error) {
317338 }318339 }319340 for _, merged := range detail.Merged {341+ // Bundles in the wild predate the per-kind count: where the342+ // account says only donorPins, the points stand for the whole343+ // offering, exactly as the writer of that day meant it.344+ counts := FeatureCounts{Point: merged.DonorPins}345+ if merged.DonorFeatures != nil {346+ counts = *merged.DonorFeatures347+ }320348 b.Merges = append(b.Merges, MergeAccount{321- Map: entry.Slug,322- Source: merged.Source,323- DonorPins: merged.DonorPins,324- Matched: merged.Matched,325- Added: merged.Added,326- Adopted: merged.Adopted,327- Held: merged.Held,328- Rejected: merged.Rejected,329- Alignment: merged.Alignment,349+ Map: entry.Slug,350+ Source: merged.Source,351+ DonorPins: counts.Point,352+ DonorFeatures: counts,353+ Matched: merged.Matched,354+ Added: merged.Added,355+ AddedShapes: merged.AddedShapes,356+ Adopted: merged.Adopted,357+ Held: merged.Held,358+ Rejected: merged.Rejected,359+ Alignment: merged.Alignment,330360 })331361 }332362 }
M tools/generate/main.go +6 -4
M tools/generate/merge.go +108 -34
@@ -98,14 +98,35 @@ type mergedSource struct {
9898 // account is simply the map's own count at composition.9999 Origin bool `json:"origin,omitempty"`100100 DonorPins int `json:"donorPins"`101- Matched []mergedPair `json:"matched,omitempty"`102- Added int `json:"added"`103- Adopted []adoptedPin `json:"adopted,omitempty"`104- Held []heldPin `json:"held,omitempty"`105- Rejected []heldPin `json:"rejected,omitempty"`106- Enriched []categoryTake `json:"enrichedCategories,omitempty"`107- Alignment string `json:"alignment,omitempty"`108-}101+ // DonorFeatures is the donor's whole offering counted per kind, points102+ // included: DonorPins repeats its point count, the older spelling kept103+ // for readers that predate shapes in the ledger.104+ DonorFeatures featureCounts `json:"donorFeatures,omitempty"`105+ Matched []mergedPair `json:"matched,omitempty"`106+ Added int `json:"added"`107+ // AddedShapes is reserved: no shape feature merges yet, so it is always108+ // zero, but the key holds the ledger's place for the day one does.109+ AddedShapes int `json:"addedShapes,omitempty"`110+ Adopted []adoptedPin `json:"adopted,omitempty"`111+ Held []heldPin `json:"held,omitempty"`112+ Rejected []heldPin `json:"rejected,omitempty"`113+ Enriched []categoryTake `json:"enrichedCategories,omitempty"`114+ Alignment string `json:"alignment,omitempty"`115+}116+117+// featureCounts counts features by kind: the three dimensionalities a118+// collection may declare, as the ledger speaks of them.119+type featureCounts struct {120+ Point int `json:"point"`121+ Path int `json:"path"`122+ Area int `json:"area"`123+}124+125+// heldShapeReason is the one reason a donor shape feature is held: matching126+// is point-only for now, so every donor path and area goes on the record127+// instead of vanishing. The gate reads the reason back off the ledger to128+// tell shape holds from point holds.129+const heldShapeReason = "shape features do not merge yet"109130 110131 // categoryTake records one attribute a serving category took from the111132 // donor's counterpart: which category, which key.
@@ -384,6 +405,19 @@ func mergeWorld(
384405 var keptCollections []worldCollection385406 for _, donorCollection := range donor.Collections {386407 if donorCollection.Kind != kindPoint {408+ // Matching is point-only, but the ledger is not: every donor409+ // path and area feature is held on the record, one line each410+ // with its title, rather than dropped without a word.411+ for _, shape := range donorCollection.Features {412+ if donorCollection.Kind == kindPath {413+ merge.DonorFeatures.Path++414+ } else {415+ merge.DonorFeatures.Area++416+ }417+ merge.Held = append(merge.Held, heldPin{418+ Donor: shape.ID, Title: shape.Title, Reason: heldShapeReason,419+ })420+ }387421 continue388422 }389423 kept := donorCollection
@@ -404,6 +438,7 @@ func mergeWorld(
404438 }405439 for _, pin := range donorCollection.Features {406440 merge.DonorPins++441+ merge.DonorFeatures.Point++407442 x, y := affine.Apply(408443 projectX(pin.Lng, donorGrid),409444 projectY(pin.Lat, donorGrid),
@@ -504,8 +539,9 @@ func mergeWorld(
504539 if err := mergeGate(merge, winner); err != nil {505540 return err506541 }507- fmt.Printf("merge %s/%s: %s: %d donor pins → %d matched (%d enriched) · %d added · %d held · %d outside\n",508- winnerGame.Slug, winner.Slug, sourceLabel, merge.DonorPins, len(merge.Matched),542+ donorTotal := merge.DonorFeatures.Point + merge.DonorFeatures.Path + merge.DonorFeatures.Area543+ fmt.Printf("merge %s/%s: %s: %d donor features → %d matched (%d enriched) · %d added · %d held · %d outside\n",544+ winnerGame.Slug, winner.Slug, sourceLabel, donorTotal, len(merge.Matched),509545 enrichedCount(merge), merge.Added, len(merge.Held), len(merge.Rejected))510546 return nil511547 }
@@ -822,14 +858,34 @@ func enrichedCount(merge *mergedSource) int {
822858 return count823859 }824860 825-// mergeGate is the merge's own audit: every donor pin accounted for, every826-// match one-to-one, no identifier doubled, nothing the serving map held made827-// worse. It fails the build rather than writing a bundle that quietly lost828-// something -- or quietly agreed too much.861+// mergeGate is the merge's own audit: every donor feature of every kind862+// accounted for, every match one-to-one, no identifier doubled, nothing the863+// serving map held made worse. It fails the build rather than writing a864+// bundle that quietly lost something -- or quietly agreed too much.829865 func mergeGate(merge *mergedSource, winner *catalogWorld) error {830- accounted := len(merge.Matched) + merge.Added + len(merge.Held) + len(merge.Rejected)831- if accounted != merge.DonorPins {832- return fmt.Errorf("merge accounts for %d of %d donor pins", accounted, merge.DonorPins)866+ // The two spellings of the donor's point count are one number, or the867+ // ledger is already lying to one of its readers.868+ if merge.DonorPins != merge.DonorFeatures.Point {869+ return fmt.Errorf("ledger speaks two point counts: donorPins %d, donorFeatures %d",870+ merge.DonorPins, merge.DonorFeatures.Point)871+ }872+ heldPoints, heldShapes := 0, 0873+ for _, held := range merge.Held {874+ if held.Reason == heldShapeReason {875+ heldShapes++876+ } else {877+ heldPoints++878+ }879+ }880+ accounted := len(merge.Matched) + merge.Added + heldPoints + len(merge.Rejected)881+ if accounted != merge.DonorFeatures.Point {882+ return fmt.Errorf("merge accounts for %d of %d donor points", accounted, merge.DonorFeatures.Point)883+ }884+ if shapes := merge.DonorFeatures.Path + merge.DonorFeatures.Area; heldShapes != shapes {885+ return fmt.Errorf("merge holds %d shape features of the %d the donor carries", heldShapes, shapes)886+ }887+ if merge.AddedShapes != 0 {888+ return fmt.Errorf("merge claims %d added shapes; no shape feature merges yet", merge.AddedShapes)833889 }834890 claimed := make(map[int64]int64)835891 for _, pair := range merge.Matched {
@@ -861,33 +917,51 @@ func mergeGate(merge *mergedSource, winner *catalogWorld) error {
861917 return fmt.Errorf("category %q took %q, which no category may carry", take.Category, take.Key)862918 }863919 }920+ // One identifier space for every kind: a point and an area may not share921+ // an id any more than two points may.864922 seen := make(map[int64]string)865- counted := 0923+ var counted featureCounts866924 for _, collection := range winner.Collections {867- if collection.Kind != kindPoint {868- continue869- }870- for _, pin := range collection.Features {871- if holder, taken := seen[pin.ID]; taken {872- return fmt.Errorf("pin id %d held by both %q and %q", pin.ID, holder, pin.Title)925+ for _, held := range collection.Features {926+ if holder, taken := seen[held.ID]; taken {927+ return fmt.Errorf("feature id %d held by both %q and %q", held.ID, holder, held.Title)928+ }929+ seen[held.ID] = held.Title930+ switch collection.Kind {931+ case kindPoint:932+ counted.Point++933+ case kindPath:934+ counted.Path++935+ default:936+ counted.Area++873937 }874- seen[pin.ID] = pin.Title875- counted++876938 }877939 }878- // The count the map claims is no longer a field to drift; it is the sum879- // of its own ledger -- what it opened with, plus what every merge says it880- // added -- and the map must actually hold that many.881- ledgered := 0940+ // The counts the map claims are no longer fields to drift; they are the941+ // sums of its own ledger -- what it opened with, plus what every merge942+ // says it added -- and the map must actually hold that many of each kind.943+ // Held shapes stay the donor's, so only the origin account speaks for the944+ // shapes the map draws.945+ var ledgered featureCounts882946 for _, account := range winner.Merged {883947 if account.Origin {884- ledgered += account.DonorPins948+ ledgered.Point += account.DonorFeatures.Point949+ ledgered.Path += account.DonorFeatures.Path950+ ledgered.Area += account.DonorFeatures.Area885951 } else {886- ledgered += account.Added952+ ledgered.Point += account.Added953+ if account.AddedShapes != 0 {954+ return fmt.Errorf("account of %s claims %d added shapes; no shape feature merges yet",955+ account.Source, account.AddedShapes)956+ }887957 }888958 }889- if counted != ledgered {890- return fmt.Errorf("world holds %d pins but its ledger claims %d", counted, ledgered)959+ if counted.Point != ledgered.Point {960+ return fmt.Errorf("world holds %d points but its ledger claims %d", counted.Point, ledgered.Point)961+ }962+ if counted.Path != ledgered.Path || counted.Area != ledgered.Area {963+ return fmt.Errorf("world holds %d paths and %d areas but its ledger claims %d and %d",964+ counted.Path, counted.Area, ledgered.Path, ledgered.Area)891965 }892966 return nil893967 }
A tools/generate/merge_test.go +225 -0
@@ -0,0 +1,225 @@
1+package main2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+)8+9+// fixtureMergeGrid is one world-sized tile: zoom 0 puts the whole longitude10+// range across a single 8192-pixel square, so fixture coordinates project11+// somewhere sensible without a tile pyramid behind them.12+func fixtureMergeGrid() tileGrid {13+ return tileGrid{SourceZoom: 0, FirstTile: 0, TileSize: 8192, Size: 8192}14+}15+16+// fixtureLandmarks builds one point collection of twelve named pins spread17+// wide enough for the alignment to stand on, with ids counted from base.18+// Both sides of a merge use the same names and places, so the fit is exact19+// and every donor pin resolves as matched.20+func fixtureLandmarks(base int64) worldCollection {21+ collection := worldCollection{22+ ID: 1, Title: "Landmarks", Group: "Markers", GroupID: 1,23+ Kind: kindPoint, Icon: "landmark", Visible: true,24+ }25+ lats := []float64{40, 10, -20}26+ lngs := []float64{-120, -60, 20, 90}27+ for atLat, lat := range lats {28+ for atLng, lng := range lngs {29+ n := int64(atLat*len(lngs) + atLng)30+ collection.Features = append(collection.Features, feature{31+ ID: base + n,32+ Title: fmt.Sprintf("Landmark %d", n),33+ Lat: lat,34+ Lng: lng,35+ })36+ }37+ }38+ return collection39+}40+41+// TestMergeWorldHoldsDonorShapeFeatures is the ledger's promise for a merge42+// no source pair exercises yet: a donor carrying areas and paths has every43+// one of them held on the record, counted in donorFeatures, and none of them44+// carried into the winner.45+func TestMergeWorldHoldsDonorShapeFeatures(t *testing.T) {46+ winnerGame := catalogVolume{47+ Slug: "hollowmere", Title: "Hollowmere",48+ Worlds: []catalogWorld{{49+ ID: 1, Slug: "overworld", UpdatedAt: "2026-01-02",50+ Collections: []worldCollection{51+ fixtureLandmarks(1),52+ {ID: 90, Key: "regions", Title: "Regions", Kind: kindArea,53+ Features: []feature{{ID: 100, Title: "Old Town"}}},54+ },55+ Merged: []mergedSource{{56+ Source: "MapGenie", Slug: "mapgenie", Origin: true,57+ DonorPins: 12,58+ DonorFeatures: featureCounts{Point: 12, Area: 1},59+ }},60+ }},61+ }62+ donorGame := catalogVolume{63+ Slug: "hollowmere", Title: "Hollowmere",64+ Worlds: []catalogWorld{{65+ ID: 2, Slug: "overworld", UpdatedAt: "2026-01-01",66+ Collections: []worldCollection{67+ fixtureLandmarks(201),68+ {ID: 91, Key: "basins", Title: "Basins", Kind: kindArea,69+ Features: []feature{70+ {ID: 301, Title: "North Basin"},71+ {ID: 302, Title: "South Basin"},72+ }},73+ {ID: 92, Key: "races", Title: "Mill Races", Kind: kindPath,74+ Features: []feature{{ID: 303, Title: "Mill Race"}}},75+ },76+ Merged: []mergedSource{{77+ Source: "IGN Wiki", Slug: "ign-wiki", Origin: true,78+ DonorPins: 12,79+ DonorFeatures: featureCounts{Point: 12, Path: 1, Area: 2},80+ }},81+ }},82+ }83+84+ winner := &winnerGame.Worlds[0]85+ err := mergeWorld(&winnerGame, winner, &donorGame, &donorGame.Worlds[0], fixtureMergeGrid())86+ if err != nil {87+ t.Fatal(err)88+ }89+ if len(winner.Merged) != 2 {90+ t.Fatalf("winner carries %d accounts, want origin plus the merge", len(winner.Merged))91+ }92+ account := winner.Merged[1]93+ if account.DonorFeatures != (featureCounts{Point: 12, Path: 1, Area: 2}) {94+ t.Errorf("donor features counted as %+v", account.DonorFeatures)95+ }96+ if account.DonorPins != account.DonorFeatures.Point {97+ t.Errorf("donorPins %d disagrees with donorFeatures.point %d",98+ account.DonorPins, account.DonorFeatures.Point)99+ }100+ if len(account.Matched) != 12 || account.Added != 0 || account.AddedShapes != 0 {101+ t.Errorf("points resolved as %d matched, %d added, %d added shapes; want 12, 0, 0",102+ len(account.Matched), account.Added, account.AddedShapes)103+ }104+ if len(account.Held) != 3 {105+ t.Fatalf("held ledger carries %d entries, want one per donor shape: %+v",106+ len(account.Held), account.Held)107+ }108+ wantHeld := []heldPin{109+ {Donor: 301, Title: "North Basin", Reason: heldShapeReason},110+ {Donor: 302, Title: "South Basin", Reason: heldShapeReason},111+ {Donor: 303, Title: "Mill Race", Reason: heldShapeReason},112+ }113+ for at, want := range wantHeld {114+ if account.Held[at] != want {115+ t.Errorf("held[%d] = %+v, want %+v", at, account.Held[at], want)116+ }117+ }118+ // The shapes are held, not carried: the winner keeps exactly the119+ // collections it opened with.120+ if len(winner.Collections) != 2 {121+ t.Errorf("winner grew to %d collections; donor shapes must not ride in", len(winner.Collections))122+ }123+}124+125+// gateWorld builds the smallest winner a gate case needs: the given126+// collections and one origin account claiming the given counts.127+func gateWorld(counts featureCounts, collections ...worldCollection) *catalogWorld {128+ return &catalogWorld{129+ Slug: "overworld",130+ Collections: collections,131+ Merged: []mergedSource{{132+ Source: "MapGenie", Origin: true,133+ DonorPins: counts.Point, DonorFeatures: counts,134+ }},135+ }136+}137+138+func TestMergeGateAccountsPerKind(t *testing.T) {139+ cases := []struct {140+ name string141+ merge *mergedSource142+ winner *catalogWorld143+ want string144+ }{145+ {146+ name: "the two point spellings must agree",147+ merge: &mergedSource{DonorPins: 3, DonorFeatures: featureCounts{Point: 2}},148+ winner: gateWorld(featureCounts{}),149+ want: "two point counts",150+ },151+ {152+ name: "every donor shape must be held",153+ merge: &mergedSource{DonorFeatures: featureCounts{Area: 1}},154+ winner: gateWorld(featureCounts{}),155+ want: "holds 0 shape features of the 1",156+ },157+ {158+ name: "a held point never settles the shape account",159+ merge: &mergedSource{DonorPins: 1, DonorFeatures: featureCounts{Point: 1, Area: 1},160+ Held: []heldPin{{Donor: 5, Title: "Well", Reason: "named like another"}}},161+ winner: gateWorld(featureCounts{}),162+ want: "holds 0 shape features of the 1",163+ },164+ {165+ name: "added shapes are reserved at zero",166+ merge: &mergedSource{AddedShapes: 1},167+ winner: gateWorld(featureCounts{}),168+ want: "no shape feature merges yet",169+ },170+ {171+ name: "one id space across kinds",172+ merge: &mergedSource{},173+ winner: gateWorld(featureCounts{Point: 1, Area: 1},174+ worldCollection{Kind: kindPoint, Features: []feature{{ID: 7, Title: "Gate"}}},175+ worldCollection{Kind: kindArea, Features: []feature{{ID: 7, Title: "Gate District"}}},176+ ),177+ want: "feature id 7 held by both",178+ },179+ {180+ name: "the ledger answers for the points the world holds",181+ merge: &mergedSource{},182+ winner: gateWorld(featureCounts{},183+ worldCollection{Kind: kindPoint, Features: []feature{{ID: 1, Title: "Gate"}}},184+ ),185+ want: "holds 1 points but its ledger claims 0",186+ },187+ {188+ name: "the ledger answers for the shapes the world holds",189+ merge: &mergedSource{},190+ winner: gateWorld(featureCounts{},191+ worldCollection{Kind: kindArea, Features: []feature{{ID: 9, Title: "Old Town"}}},192+ ),193+ want: "holds 0 paths and 1 areas but its ledger claims 0 and 0",194+ },195+ {196+ name: "a consistent account of every kind passes",197+ merge: &mergedSource{DonorPins: 1, DonorFeatures: featureCounts{Point: 1, Path: 1, Area: 1},198+ Held: []heldPin{199+ {Donor: 5, Title: "Well", Reason: "named like another"},200+ {Donor: 6, Title: "Race", Reason: heldShapeReason},201+ {Donor: 7, Title: "Basin", Reason: heldShapeReason},202+ }},203+ winner: gateWorld(featureCounts{Point: 1, Path: 1, Area: 1},204+ worldCollection{Kind: kindPoint, Features: []feature{{ID: 1, Title: "Gate"}}},205+ worldCollection{Kind: kindPath, Features: []feature{{ID: 2, Title: "Canal"}}},206+ worldCollection{Kind: kindArea, Features: []feature{{ID: 3, Title: "Old Town"}}},207+ ),208+ want: "",209+ },210+ }211+ for _, c := range cases {212+ t.Run(c.name, func(t *testing.T) {213+ err := mergeGate(c.merge, c.winner)214+ if c.want == "" {215+ if err != nil {216+ t.Fatalf("gate refused a consistent account: %v", err)217+ }218+ return219+ }220+ if err == nil || !strings.Contains(err.Error(), c.want) {221+ t.Fatalf("gate said %v, want it to say %q", err, c.want)222+ }223+ })224+ }225+}
M tools/generate/normalize.go +19 -7
M tools/maturity/main.go +2 -2