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 / align / align_test.go
align_test.go 6.1K
1package align23import (4 "fmt"5 "math"6 "strings"7 "testing"8)910// anchors builds a donor set and its image under a known transformation, so a11// test can ask whether the fit recovers what it was given.12func anchors(count int, apply func(x, y float64) (float64, float64)) (donor, base []Anchor) {13 for index := range count {14 x := float64(100 + index*37)15 y := float64(50 + (index*53)%400)16 title := fmt.Sprintf("place %d", index)17 donor = append(donor, Anchor{Title: title, X: x, Y: y})18 bx, by := apply(x, y)19 base = append(base, Anchor{Title: title, X: bx, Y: by})20 }21 return donor, base22}2324func TestFitRecoversTheTransformationItWasGiven(t *testing.T) {25 cases := []struct {26 what string27 apply func(x, y float64) (float64, float64)28 }{29 {"a translation", func(x, y float64) (float64, float64) { return x + 300, y - 120 }},30 {"a scale", func(x, y float64) (float64, float64) { return 2*x + 5, 2*y - 5 }},31 {"a small rotation", func(x, y float64) (float64, float64) {32 const angle = 0.0333 return math.Cos(angle)*x - math.Sin(angle)*y + 10,34 math.Sin(angle)*x + math.Cos(angle)*y - 1035 }},36 }37 for _, c := range cases {38 t.Run(c.what, func(t *testing.T) {39 donor, base := anchors(40, c.apply)40 affine, report, err := Fit(donor, base)41 if err != nil {42 t.Fatal(err)43 }44 if report.MedianPx > 1e-6 || report.WorstPx > 1e-6 {45 t.Errorf("an exact transformation fitted with residuals: %s", report)46 }47 for _, anchor := range donor {48 wantX, wantY := c.apply(anchor.X, anchor.Y)49 gotX, gotY := affine.Apply(anchor.X, anchor.Y)50 if math.Hypot(gotX-wantX, gotY-wantY) > 1e-6 {51 t.Fatalf("%s landed at %.3f,%.3f, want %.3f,%.3f",52 anchor.Title, gotX, gotY, wantX, wantY)53 }54 }55 back, ok := affine.Invert()56 if !ok {57 t.Fatal("the transformation would not invert")58 }59 x, y := back.Apply(affine.Apply(donor[0].X, donor[0].Y))60 if math.Hypot(x-donor[0].X, y-donor[0].Y) > 1e-6 {61 t.Errorf("a round trip through the inverse landed at %.3f,%.3f", x, y)62 }63 })64 }65}6667func TestFitRefusesRatherThanGuesses(t *testing.T) {68 shift := func(x, y float64) (float64, float64) { return x + 10, y + 10 }6970 t.Run("too few shared names", func(t *testing.T) {71 donor, base := anchors(MinAnchors-1, shift)72 if _, _, err := Fit(donor, base); err == nil ||73 !strings.Contains(err.Error(), "unambiguous shared names") {74 t.Fatalf("error %v", err)75 }76 })7778 t.Run("names that are not shared", func(t *testing.T) {79 donor, base := anchors(30, shift)80 for index := range base {81 base[index].Title = "different " + base[index].Title82 }83 if _, _, err := Fit(donor, base); err == nil {84 t.Fatal("a fit stood on nothing")85 }86 })8788 t.Run("a name that appears twice is no anchor", func(t *testing.T) {89 donor, base := anchors(30, shift)90 for index := range donor {91 donor[index].Title = "the same place"92 base[index].Title = "the same place"93 }94 if _, _, err := Fit(donor, base); err == nil {95 t.Fatal("ambiguous names were used as calibration")96 }97 })9899 t.Run("collinear anchors are degenerate", func(t *testing.T) {100 var donor, base []Anchor101 for index := range 30 {102 title := fmt.Sprintf("place %d", index)103 donor = append(donor, Anchor{Title: title, X: float64(index), Y: 0})104 base = append(base, Anchor{Title: title, X: float64(index), Y: 0})105 }106 if _, _, err := Fit(donor, base); err == nil {107 t.Fatal("a transformation stood on a line")108 }109 })110111 t.Run("an alignment that will not close", func(t *testing.T) {112 donor, base := anchors(40, shift)113 // Scatter the base far beyond what trimming can shed.114 for index := range base {115 base[index].X += float64((index%7)*400) - 800116 base[index].Y -= float64((index%5)*300) - 600117 }118 _, report, err := Fit(donor, base)119 if err == nil {120 t.Fatalf("a fit with a median of %.1fpx was accepted", report.MedianPx)121 }122 if !strings.Contains(err.Error(), "does not close") {123 t.Fatalf("refused for the wrong reason: %v", err)124 }125 })126}127128func TestFitTrimsTheWorstAnchors(t *testing.T) {129 donor, base := anchors(40, func(x, y float64) (float64, float64) { return x + 10, y + 10 })130 // Four anchors one source simply placed badly.131 for index := range 4 {132 base[index].X += 900133 }134 affine, report, err := Fit(donor, base)135 if err != nil {136 t.Fatal(err)137 }138 if report.Anchors >= 40 {139 t.Errorf("trimming kept all %d anchors", report.Anchors)140 }141 if report.MedianPx > 1 {142 t.Errorf("the sloppy anchors dragged the fit: %s", report)143 }144 x, y := affine.Apply(1000, 1000)145 if math.Hypot(x-1010, y-1010) > 1 {146 t.Errorf("the fit landed 1000,1000 at %.1f,%.1f", x, y)147 }148}149150func TestFitIsPure(t *testing.T) {151 donor, base := anchors(30, func(x, y float64) (float64, float64) { return 1.5*x + 3, 1.5*y - 3 })152 first, firstReport, err := Fit(donor, base)153 if err != nil {154 t.Fatal(err)155 }156 second, secondReport, err := Fit(donor, base)157 if err != nil {158 t.Fatal(err)159 }160 if first != second || firstReport != secondReport {161 t.Errorf("two fits of one set disagree:\n%+v %s\n%+v %s",162 first, firstReport, second, secondReport)163 }164}165166func TestNormalizeTitleFoldsTheSpellingsApart(t *testing.T) {167 cases := []struct{ in, want string }{168 {"Northside Apartment", "northside apartment"},169 {"NORTHSIDE Apartment", "northside apartment"},170 {"Afterlife - Fast Travel", "afterlife"},171 {"Afterlife Fast Travel Point", "afterlife"},172 {"Ripperdoc: Vik's", "ripperdoc vik s"},173 {" ", ""},174 {"", ""},175 }176 for _, c := range cases {177 if got := NormalizeTitle(c.in); got != c.want {178 t.Errorf("%q normalized to %q, want %q", c.in, got, c.want)179 }180 }181}182183func TestTokensAreTheWordsOfAName(t *testing.T) {184 tokens := Tokens(NormalizeTitle("Northside, Watson Apartment"))185 for _, word := range []string{"northside", "watson", "apartment"} {186 if !tokens[word] {187 t.Errorf("%q is not among the tokens %v", word, tokens)188 }189 }190 if len(Tokens("")) != 0 {191 t.Error("an empty name has tokens")192 }193}194195func TestReportReadsAsTheLedgerRecordsIt(t *testing.T) {196 report := Report{Anchors: 99, MedianPx: 26.0, P90Px: 52.0, WorstPx: 67.4}197 const want = "99 anchors, median 26.0px, p90 52.0px, worst 67.4px"198 if got := report.String(); got != want {199 t.Errorf("report reads %q, want %q", got, want)200 }201}