1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
package main

import (
	"database/sql"
	"path/filepath"
	"testing"
)

func testDB(t *testing.T) *sql.DB {
	t.Helper()
	db, err := openDB(filepath.Join(t.TempDir(), "test.db"), writeBusyTimeout)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	return db
}

func insertPlace(t *testing.T, db *sql.DB, qid, label string, lat, lon any) {
	t.Helper()
	_, err := db.Exec(`INSERT INTO place (qid,label,country,country_label,lat,lon,pop) VALUES (?,?,?,?,?,?,?)`,
		qid, label, "Q183", "Germany", lat, lon, 1000)
	if err != nil {
		t.Fatal(err)
	}
}

// The storage is normalised to a<b, but "the twins of X" must find X on
// either side of the stored pair. This is the UNION in twinsOf, and getting
// it wrong would make half of every city's twins invisible.
func TestTwinsOfBothDirections(t *testing.T) {
	db := testDB(t)
	insertPlace(t, db, "Q1", "Alpha", 1.0, 1.0)
	insertPlace(t, db, "Q2", "Beta", 2.0, 2.0)
	insertPlace(t, db, "Q3", "Gamma", 3.0, 3.0)
	// Q2 appears as the b-side of one pair and the a-side of the other.
	mustExec(t, db, `INSERT INTO twin (a,b) VALUES ('Q1','Q2'), ('Q2','Q3')`)

	twins, err := twinsOf(db, "Q2")
	if err != nil {
		t.Fatal(err)
	}
	if len(twins) != 2 {
		t.Fatalf("got %d twins for Q2, want 2 (one from each direction)", len(twins))
	}
	if twins[0].Label != "Alpha" || twins[1].Label != "Gamma" {
		t.Errorf("twins = %v, want Alpha and Gamma sorted by label", twins)
	}
}

// A place with no coordinates must still be listed as a twin — it just
// cannot be drawn. This is the ~110-entity case from the CAVEATS.
func TestTwinsOfIncludesCoordless(t *testing.T) {
	db := testDB(t)
	insertPlace(t, db, "Q1", "Alpha", 1.0, 1.0)
	insertPlace(t, db, "Q2", "Nowhere", nil, nil)
	mustExec(t, db, `INSERT INTO twin (a,b) VALUES ('Q1','Q2')`)

	twins, err := twinsOf(db, "Q1")
	if err != nil {
		t.Fatal(err)
	}
	if len(twins) != 1 {
		t.Fatalf("got %d twins, want 1", len(twins))
	}
	if twins[0].HasCoord {
		t.Error("Nowhere has no coordinates and must be flagged as such")
	}
}

// loadMap feeds the map, so it must expose only plottable places, and only
// edges whose both endpoints are plottable — an arc needs two ends.
func TestLoadMapSkipsUnplottable(t *testing.T) {
	db := testDB(t)
	insertPlace(t, db, "Q1", "Alpha", 1.0, 1.0)
	insertPlace(t, db, "Q2", "Beta", 2.0, 2.0)
	insertPlace(t, db, "Q3", "Nowhere", nil, nil)
	mustExec(t, db, `INSERT INTO twin (a,b) VALUES ('Q1','Q2'), ('Q1','Q3')`)

	places, edges, err := loadMap(db)
	if err != nil {
		t.Fatal(err)
	}
	if len(places) != 2 {
		t.Fatalf("got %d places, want 2 (Nowhere excluded)", len(places))
	}
	if len(edges) != 1 {
		t.Fatalf("got %d edges, want 1 (the edge to Nowhere is undrawable)", len(edges))
	}
	// Edges are indices into the places slice; they must actually resolve.
	a, b := edges[0].A, edges[0].B
	if int(a) >= len(places) || int(b) >= len(places) {
		t.Fatalf("edge %v out of range for %d places", edges[0], len(places))
	}
	if places[a].Q != "Q1" || places[b].Q != "Q2" {
		t.Errorf("edge connects %s-%s, want Q1-Q2", places[a].Q, places[b].Q)
	}
}

// The CHECK constraint is the last line of defence against a denormalised
// write doubling every edge.
func TestTwinRejectsUnnormalised(t *testing.T) {
	db := testDB(t)
	insertPlace(t, db, "Q1", "Alpha", 1.0, 1.0)
	insertPlace(t, db, "Q2", "Beta", 2.0, 2.0)
	if _, err := db.Exec(`INSERT INTO twin (a,b) VALUES ('Q2','Q1')`); err == nil {
		t.Error("inserting a>b succeeded; the CHECK constraint is not enforced")
	}
}

func TestStoreReplacesAtomically(t *testing.T) {
	db := testDB(t)
	places := map[string]*place{
		"Q1": {qid: "Q1", label: "Alpha", lat: sql.NullFloat64{Float64: 1, Valid: true}, lon: sql.NullFloat64{Float64: 1, Valid: true}},
		"Q2": {qid: "Q2", label: "Beta"},
	}
	if err := store(db, places, map[pair]*pairInfo{{"Q1", "Q2"}: {started: "1987-06-25"}}); err != nil {
		t.Fatal(err)
	}
	// A second ingest with different data must replace, not accumulate.
	places2 := map[string]*place{"Q3": {qid: "Q3", label: "Gamma"}, "Q4": {qid: "Q4", label: "Delta"}}
	if err := store(db, places2, map[pair]*pairInfo{{"Q3", "Q4"}: {}}); err != nil {
		t.Fatal(err)
	}

	s, err := loadStats(db)
	if err != nil {
		t.Fatal(err)
	}
	if s.Places != 2 || s.Pairs != 1 {
		t.Errorf("after re-ingest: %d places, %d pairs; want 2 and 1", s.Places, s.Pairs)
	}
	if s.IngestedAt == "" {
		t.Error("ingested_at not recorded")
	}
}

func mustExec(t *testing.T, db *sql.DB, q string) {
	t.Helper()
	if _, err := db.Exec(q); err != nil {
		t.Fatal(err)
	}
}