kenro 0.4.0

SpatiaLite-style spatial SQL for SQLite in pure Rust — PostGIS-compatible ST_ functions, GeoPackage R-tree, CRS transform, H3, MVT. Use via rusqlite, loadable extension, or WASM
Documentation
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
package kenro

import (
	"context"
	"database/sql"
	"fmt"
	"math"
	"slices"
	"strings"
	"testing"

	_ "modernc.org/sqlite"
)

// Geometry fixtures shared by the smoke cases.
const (
	poly  = `ST_GeomFromText('POLYGON((0 0,2 0,2 3,0 3,0 0))', 4326)`
	poly2 = `ST_GeomFromText('POLYGON((1 0,3 0,3 3,1 3,1 0))', 4326)`
	line  = `ST_GeomFromText('LINESTRING(0 0,1 1,2 2)', 4326)`
	pt    = `ST_GeomFromText('POINT(1 2)', 4326)`
	pt2   = `ST_GeomFromText('POINT(4 6)', 4326)`
	// 3D operands arrive as blob literals: ST_GeomFromText refuses a Z by
	// design, which is a documented divergence, not an oversight.
	ptZ = `x'01e9030000000000000000000000000000000000000000000000000000'` // POINT Z (0 0 0)
	// A closed, outward-oriented unit cube as POLYHEDRALSURFACE Z: 3D area 6,
	// enclosed volume 1. Written as WKB because kenro's WKT reader refuses Z.
	cubeZ = `x'01f70300000600000001eb03000001000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f0000000000000000000000000000000000000000000000000000000000000000000000000000000001eb030000010000000500000000000000000000000000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f000000000000f03f000000000000f03f000000000000f03f0000000000000000000000000000f03f000000000000f03f00000000000000000000000000000000000000000000f03f01eb0300000100000005000000000000000000000000000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f00000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000000001eb0300000100000005000000000000000000f03f00000000000000000000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f000000000000f03f000000000000f03f000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000000001eb0300000100000005000000000000000000f03f000000000000f03f00000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000000001eb03000001000000050000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f0000000000000000'`
	ptZ2  = `x'01e9030000000000000000f03f000000000000f03f000000000000f03f'` // POINT Z (1 1 1)
	// LINESTRING Z (0 0 0,10 0 100)
	lineZ = `x'01ea03000002000000000000000000000000000000000000000000000000000000000000000000244000000000000000000000000000005940'`
)

func open(t *testing.T) *sql.DB {
	t.Helper()
	if err := Register(); err != nil {
		t.Fatalf("Register: %v", err)
	}
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { db.Close() })
	return db
}

func value(t *testing.T, db *sql.DB, query string) any {
	t.Helper()
	var v any
	if err := db.QueryRow("SELECT " + query).Scan(&v); err != nil {
		t.Fatalf("%s: %v", query, err)
	}
	return v
}

// A case per SQL function kenro registers; TestSmokeCoversTheManifest checks
// that this list stays exhaustive.
type smokeCase struct {
	fn   string
	sql  string
	want any // nil = only assert that it runs and is non-NULL
}

var smokeCases = []smokeCase{
	// I/O
	{"ST_GeomFromText", `ST_AsText(` + pt + `)`, "POINT(1 2)"},
	{"ST_AsText", `ST_AsText(` + line + `)`, "LINESTRING(0 0,1 1,2 2)"},
	{"ST_AsBinary", `length(ST_AsBinary(` + pt + `))`, int64(21)},
	{"ST_GeomFromWKB", `ST_AsText(ST_GeomFromWKB(ST_AsBinary(` + pt + `), 4326))`, "POINT(1 2)"},
	{"ST_AsGPB", `length(ST_AsGPB(` + pt + `)) > 0`, int64(1)},
	{"ST_GeomFromGPB", `ST_AsText(ST_GeomFromGPB(ST_AsGPB(` + pt + `)))`, "POINT(1 2)"},
	{"ST_SetSRID", `ST_SRID(ST_SetSRID(` + pt + `, 3857))`, int64(3857)},
	{"ST_SRID", `ST_SRID(` + pt + `)`, int64(4326)},

	// Predicates
	{"ST_Intersects", `ST_Intersects(` + poly + `, ` + pt + `)`, int64(1)},
	{"ST_Contains", `ST_Contains(` + poly + `, ` + pt + `)`, int64(1)},
	{"ST_Within", `ST_Within(` + pt + `, ` + poly + `)`, int64(1)},
	{"ST_Disjoint", `ST_Disjoint(` + poly + `, ` + pt2 + `)`, int64(1)},
	{"ST_Touches", `ST_Touches(` + poly + `, ` + pt + `)`, int64(0)},
	{"ST_Crosses", `ST_Crosses(` + line + `, ` + poly + `)`, nil},
	{"ST_Overlaps", `ST_Overlaps(` + poly + `, ` + poly2 + `)`, int64(1)},
	{"ST_Equals", `ST_Equals(` + poly + `, ` + poly + `)`, int64(1)},
	{"ST_Covers", `ST_Covers(` + poly + `, ` + pt + `)`, int64(1)},
	{"ST_CoveredBy", `ST_CoveredBy(` + pt + `, ` + poly + `)`, int64(1)},
	{"ST_Relate", `length(ST_Relate(` + poly + `, ` + poly2 + `))`, int64(9)},
	{"ST_Distance", `ST_Distance(` + poly + `, ` + pt2 + `)`, nil},
	{"ST_DWithin", `ST_DWithin(` + pt + `, ` + pt2 + `, 100.0)`, int64(1)},

	// GeoPackage R-tree helpers
	{"ST_MinX", `ST_MinX(` + poly + `)`, 0.0},
	{"ST_MaxX", `ST_MaxX(` + poly + `)`, 2.0},
	{"ST_MinY", `ST_MinY(` + poly + `)`, 0.0},
	{"ST_MaxY", `ST_MaxY(` + poly + `)`, 3.0},
	{"ST_IsEmpty", `ST_IsEmpty(` + poly + `)`, int64(0)},
	{"GPKG_IsAssignable", `GPKG_IsAssignable('GEOMETRY', 'POINT')`, int64(1)},

	// CRS / GeoJSON / H3
	{"ST_Transform", `ST_AsText(ST_Transform(` + pt + `, 3857)) LIKE 'POINT(111319%'`, int64(1)},
	{"ST_AsGeoJSON", `ST_AsGeoJSON(` + pt + `)`, `{"type":"Point","coordinates":[1,2]}`},
	{"ST_GeomFromGeoJSON", `ST_AsText(ST_GeomFromGeoJSON('{"type":"Point","coordinates":[1,2]}'))`, "POINT(1 2)"},
	{"h3_latlng_to_cell", `h3_latlng_to_cell(` + pt + `, 9) != 0`, int64(1)},
	{"h3_cell_to_parent", `h3_cell_to_parent(h3_latlng_to_cell(` + pt + `, 9), 5) != 0`, int64(1)},
	{"h3_cell_to_string", `length(h3_cell_to_string(h3_latlng_to_cell(` + pt + `, 9)))`, int64(15)},
	{"h3_string_to_cell", `h3_string_to_cell(h3_cell_to_string(h3_latlng_to_cell(` + pt + `, 9))) = h3_latlng_to_cell(` + pt + `, 9)`, int64(1)},

	// Constructors
	{"ST_MakePoint", `ST_AsText(ST_MakePoint(1, 2))`, "POINT(1 2)"},
	// The only functions that create a Z. ST_NDims reports it; ST_AsText would
	// refuse, kenro having no 3D text writer.
	{"ST_Force3D", `ST_NDims(ST_Force3D(` + pt + `))`, int64(3)},
	// The core-PostGIS 3D metric family. Values measured on PostGIS 3.5.
	{"ST_3DDistance", `ST_3DDistance(` + ptZ + `, ` + ptZ2 + `)`, nil},
	{"ST_3DMaxDistance", `ST_3DMaxDistance(` + ptZ + `, ` + ptZ2 + `)`, nil},
	{"ST_3DIntersects", `ST_3DIntersects(` + ptZ + `, ` + ptZ + `)`, int64(1)},
	{"ST_3DDWithin", `ST_3DDWithin(` + ptZ + `, ` + ptZ2 + `, 100.0)`, int64(1)},
	{"ST_3DDFullyWithin", `ST_3DDFullyWithin(` + ptZ + `, ` + ptZ2 + `, 100.0)`, int64(1)},
	{"ST_3DClosestPoint", `ST_Z(ST_3DClosestPoint(` + ptZ + `, ` + ptZ2 + `))`, nil},
	{"ST_3DShortestLine", `ST_NDims(ST_3DShortestLine(` + ptZ + `, ` + ptZ2 + `))`, int64(3)},
	{"ST_3DLongestLine", `ST_NDims(ST_3DLongestLine(` + ptZ + `, ` + ptZ2 + `))`, int64(3)},
	{"ST_3DLineInterpolatePoint", `ST_Z(ST_3DLineInterpolatePoint(` + lineZ + `, 0.5))`, nil},
	// The two SFCGAL measurements. A surface is not a solid in SFCGAL, which is
	// why the volume wears a kenro_ name — see docs/3d.md.
	{"ST_3DArea", `ST_3DArea(` + cubeZ + `)`, 6.0},
	{"kenro_volume", `kenro_volume(` + cubeZ + `)`, 1.0},
	{"ST_Force3DZ", `ST_Z(ST_Force3DZ(` + pt + `, 7))`, 7.0},
	{"ST_Point", `ST_SRID(ST_Point(1, 2, 4326))`, int64(4326)},
	{"ST_MakeEnvelope", `ST_AsText(ST_MakeEnvelope(0, 0, 1, 1, 4326))`, "POLYGON((0 0,0 1,1 1,1 0,0 0))"},

	// Measures
	{"ST_ClosestPoint", `ST_AsText(ST_ClosestPoint(` + line + `, ` + pt + `))`, nil},
	{"ST_LineInterpolatePoint", `ST_AsText(ST_LineInterpolatePoint(` + line + `, 0.5))`, "POINT(1 1)"},
	{"ST_LineLocatePoint", `ST_LineLocatePoint(` + line + `, ` + pt + `)`, nil},
	{"ST_HausdorffDistance", `ST_HausdorffDistance(` + line + `, ` + line + `)`, 0.0},
	{"ST_FrechetDistance", `ST_FrechetDistance(` + line + `, ` + line + `)`, 0.0},
	{"ST_Azimuth", `ST_Azimuth(` + pt + `, ` + pt2 + `)`, nil},

	// Overlay
	{"ST_Intersection", `ST_Area(ST_Intersection(` + poly + `, ` + poly2 + `))`, 3.0},
	{"ST_Difference", `ST_Area(ST_Difference(` + poly + `, ` + poly2 + `))`, 3.0},
	{"ST_SymDifference", `ST_Area(ST_SymDifference(` + poly + `, ` + poly2 + `))`, 6.0},
	{"ST_Buffer", `ST_Area(ST_Buffer(` + pt + `, 1)) > 3`, int64(1)},
	{"ST_MakeValid", `ST_Area(ST_MakeValid(` + poly + `))`, 6.0},

	// MVT
	{"ST_AsMVTGeom", `ST_AsMVTGeom(` + poly + `, ST_MakeEnvelope(0, 0, 4, 4, 4326)) IS NOT NULL`, int64(1)},

	// Processing
	{"ST_ConvexHull", `ST_Area(ST_ConvexHull(` + poly + `))`, 6.0},
	{"ST_PointOnSurface", `ST_Intersects(` + poly + `, ST_PointOnSurface(` + poly + `))`, int64(1)},
	{"ST_SimplifyVW", `ST_AsText(ST_SimplifyVW(` + line + `, 0.0001))`, nil},
	{"ST_ChaikinSmoothing", `ST_AsText(ST_ChaikinSmoothing(` + line + `, 1))`, nil},
	{"ST_RemoveRepeatedPoints", `ST_AsText(ST_RemoveRepeatedPoints(` + line + `))`, "LINESTRING(0 0,1 1,2 2)"},
	{"ST_OrientedEnvelope", `ST_Area(ST_OrientedEnvelope(` + poly + `))`, 6.0},

	// Affine
	{"ST_Rotate", `ST_AsText(ST_Rotate(` + pt + `, 0))`, "POINT(1 2)"},
	{"ST_Translate", `ST_AsText(ST_Translate(` + pt + `, 1, 1))`, "POINT(2 3)"},
	{"ST_Scale", `ST_AsText(ST_Scale(` + pt + `, 2, 2))`, "POINT(2 4)"},

	// Accessors
	{"ST_Area", `ST_Area(` + poly + `)`, 6.0},
	{"ST_NPoints", `ST_NPoints(` + poly + `)`, int64(5)},
	{"ST_Perimeter", `ST_Perimeter(` + poly + `)`, 10.0},
	{"ST_GeometryType", `ST_GeometryType(` + poly + `)`, "ST_Polygon"},
	{"ST_NumGeometries", `ST_NumGeometries(` + poly + `)`, int64(1)},
	{"ST_GeometryN", `ST_AsText(ST_GeometryN(` + poly + `, 1))`, nil},
	{"ST_StartPoint", `ST_AsText(ST_StartPoint(` + line + `))`, "POINT(0 0)"},
	{"ST_EndPoint", `ST_AsText(ST_EndPoint(` + line + `))`, "POINT(2 2)"},
	{"ST_PointN", `ST_AsText(ST_PointN(` + line + `, 2))`, "POINT(1 1)"},
	{"ST_Reverse", `ST_AsText(ST_Reverse(` + line + `))`, "LINESTRING(2 2,1 1,0 0)"},
	{"ST_Length", `ST_Length(` + line + `) > 2`, int64(1)},
	{"ST_Centroid", `ST_AsText(ST_Centroid(` + poly + `))`, "POINT(1 1.5)"},
	{"ST_Envelope", `ST_Area(ST_Envelope(` + poly + `))`, 6.0},
	{"ST_X", `ST_X(` + pt + `)`, 1.0},
	{"ST_Y", `ST_Y(` + pt + `)`, 2.0},
	{"ST_NumPoints", `ST_NumPoints(` + line + `)`, int64(3)},
	{"ST_IsValid", `ST_IsValid(` + poly + `)`, int64(1)},
	{"ST_Simplify", `ST_AsText(ST_Simplify(` + line + `, 0.0001))`, nil},

	// Aggregates (ST_Union's scalar form is covered in TestUnionMode).
	{"ST_Union", `(SELECT ST_Area(ST_Union(g)) FROM (SELECT ` + poly + ` AS g UNION ALL SELECT ` + poly2 + `))`, 9.0},
	{"ST_AsMVT", `(SELECT length(ST_AsMVT(ST_AsMVTGeom(g, ST_MakeEnvelope(0, 0, 4, 4, 4326)), 'layer')) > 0 FROM (SELECT ` + poly + ` AS g))`, int64(1)},

	// --- the PostGIS surface added in the T1-T4 phases ---
	// SQL is shared with the JS smoke catalog, so the two bindings exercise
	// the same expressions; tolerance-based checks assert only non-NULL here.
	{"ST_AddPoint", `ST_AsText(ST_AddPoint(ST_GeomFromText('LINESTRING(0 0,1 1)'), ST_GeomFromText('POINT(2 2)')))`, "LINESTRING(0 0,1 1,2 2)"},
	{"ST_Affine", `ST_AsText(ST_Affine(ST_GeomFromText('LINESTRING(1 2,3 4)'), 2,0,0,2,10,20))`, "LINESTRING(12 24,16 28)"},
	{"ST_Angle", `ST_Angle(ST_GeomFromText('POINT(1 0)'), ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(0 1)'))`, nil},
	{"ST_Area2D", `ST_Area2D(ST_GeomFromText('POLYGON((0 0,2 0,2 2,0 2,0 0))'))`, int64(4)},
	{"ST_AsEWKB", `ST_SRID(ST_GeomFromEWKB(ST_AsEWKB(ST_GeomFromText('POINT(1 2)', 4326))))`, int64(4326)},
	{"ST_AsEWKT", `ST_AsEWKT(ST_GeomFromText('POINT(1 2)', 4326))`, "SRID=4326;POINT(1 2)"},
	{"ST_AsHexEWKB", `ST_AsHexEWKB(ST_GeomFromText('POINT(1 2)', 4326))`, "0101000020E6100000000000000000F03F0000000000000040"},
	{"ST_Boundary", `ST_AsText(ST_Boundary(ST_GeomFromText('LINESTRING(0 0,1 1,2 0)')))`, "MULTIPOINT((0 0),(2 0))"},
	{"ST_BoundingDiagonal", `ST_AsText(ST_BoundingDiagonal(ST_GeomFromText('LINESTRING(1 2,5 9)')))`, "LINESTRING(1 2,5 9)"},
	{"ST_ClipByBox2D", `ST_Area(ST_ClipByBox2D(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))'), ST_MakeEnvelope(2,2,5,5)))`, nil},
	{"ST_ConcaveHull", `ST_Area(ST_ConcaveHull(ST_GeomFromText('MULTIPOINT(0 0,4 0,4 4,0 4)'), 1.0))`, nil},
	{"ST_ContainsProperly", `ST_ContainsProperly(ST_GeomFromText('POLYGON((0 0,3 0,3 3,0 3,0 0))'), ST_GeomFromText('POINT(1 1)'))`, int64(1)},
	{"ST_CoordDim", `ST_CoordDim(ST_GeomFromText('POINT(1 2)'))`, int64(2)},
	{"ST_DFullyWithin", `ST_DFullyWithin(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('LINESTRING(2 -1,2 1)'), 3)`, int64(1)},
	{"ST_DelaunayTriangles", `ST_NumGeometries(ST_DelaunayTriangles(ST_GeomFromText('MULTIPOINT(0 0,4 0,4 4,0 4)')))`, int64(2)},
	{"ST_AsKML", `ST_AsKML(ST_GeomFromText('POINT(1 2)', 4326))`, "<Point><coordinates>1,2</coordinates></Point>"},
	{"ST_AsSVG", `ST_AsSVG(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0))'))`, "M 0 0 L 4 0 4 -4 0 -4 Z"},
	{"ST_SquareGrid", `ST_NumGeometries(ST_SquareGrid(1, ST_GeomFromText('POLYGON((0 0,3 0,3 2,0 2,0 0))')))`, int64(12)},
	{"ST_HexagonGrid", `ST_NumGeometries(ST_HexagonGrid(1, ST_GeomFromText('POLYGON((0 0,3 0,3 3,0 3,0 0))')))`, int64(8)},
	{"ST_VoronoiPolygons", `ST_Area(ST_VoronoiPolygons(ST_GeomFromText('MULTIPOINT(0 0,4 0,4 4,0 4)')))`, 144.0},
	{"ST_VoronoiLines", `ST_GeometryType(ST_VoronoiLines(ST_GeomFromText('MULTIPOINT(0 0,4 0,4 4,0 4)')))`, "ST_MultiLineString"},
	{"ST_TriangulatePolygon", `ST_Area(ST_TriangulatePolygon(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0),(2 2,4 2,4 4,2 4,2 2))')))`, 96.0},
	{"ST_IsSimple", `ST_IsSimple(ST_GeomFromText('LINESTRING(0 0,10 10,0 10,10 0)'))`, int64(0)},
	{"ST_LineMerge", `ST_AsText(ST_LineMerge(ST_GeomFromText('MULTILINESTRING((0 0,1 1),(1 1,2 2))')))`, "LINESTRING(0 0,1 1,2 2)"},
	{"ST_Split", `ST_Area(ST_Split(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))'), ST_GeomFromText('LINESTRING(5 -1,5 11)')))`, 100.0},
	{"ST_Dimension", `ST_Dimension(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 0))'))`, int64(2)},
	{"ST_DistanceSphere", `ST_DistanceSphere(ST_GeomFromText('POINT(0 0)', 4326), ST_GeomFromText('POINT(1 0)', 4326))`, nil},
	{"ST_DistanceSpheroid", `ST_DistanceSpheroid(ST_GeomFromText('POINT(0 0)', 4326), ST_GeomFromText('POINT(1 0)', 4326))`, nil},
	{"ST_Expand", `ST_AsText(ST_Expand(ST_GeomFromText('POINT(1 1)'), 2))`, "POLYGON((-1 -1,-1 3,3 3,3 -1,-1 -1))"},
	{"ST_ExteriorRing", `ST_AsText(ST_ExteriorRing(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))')))`, "LINESTRING(0 0,4 0,4 4,0 4,0 0)"},
	{"ST_FlipCoordinates", `ST_AsText(ST_FlipCoordinates(ST_GeomFromText('POINT(1 2)')))`, "POINT(2 1)"},
	{"ST_Force2D", `ST_AsText(ST_Force2D(ST_GeomFromText('POINT(1 2)')))`, "POINT(1 2)"},
	{"ST_ForcePolygonCCW", `ST_IsPolygonCCW(ST_ForcePolygonCCW(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))')))`, int64(1)},
	{"ST_ForcePolygonCW", `ST_IsPolygonCW(ST_ForcePolygonCW(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))')))`, int64(1)},
	{"ST_ForceRHR", `ST_IsPolygonCW(ST_ForceRHR(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))')))`, int64(1)},
	{"ST_GeoHash", `ST_GeoHash(ST_GeomFromText('POINT(139.7 35.68)', 4326))`, "xn76fzq7jfn42q30gmb9"},
	{"ST_GeomFromEWKB", `ST_AsText(ST_GeomFromEWKB(ST_AsEWKB(ST_GeomFromText('POINT(1 2)', 4326))))`, "POINT(1 2)"},
	{"ST_GeomFromEWKT", `ST_AsEWKT(ST_GeomFromEWKT('SRID=3857;POINT(1 2)'))`, "SRID=3857;POINT(1 2)"},
	{"ST_GeometryFromText", `ST_AsText(ST_GeometryFromText('POINT(1 2)'))`, "POINT(1 2)"},
	{"ST_InteriorRingN", `ST_AsText(ST_InteriorRingN(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))'), 1))`, "LINESTRING(1 1,2 1,2 2,1 2,1 1)"},
	{"ST_IsClosed", `ST_IsClosed(ST_GeomFromText('LINESTRING(0 0,1 1,1 0,0 0)'))`, int64(1)},
	{"ST_IsPolygonCCW", `ST_IsPolygonCCW(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))`, int64(1)},
	{"ST_IsPolygonCW", `ST_IsPolygonCW(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))`, int64(0)},
	{"ST_IsRing", `ST_IsRing(ST_GeomFromText('LINESTRING(0 0,1 1,1 0,0 0)'))`, int64(1)},
	{"ST_IsValidReason", `ST_IsValidReason(ST_GeomFromText('POINT(1 1)'))`, "Valid Geometry"},
	{"ST_Length2D", `ST_Length2D(ST_GeomFromText('LINESTRING(0 0,3 4)'))`, int64(5)},
	{"ST_Length2DSpheroid", `ST_Length2DSpheroid(ST_GeomFromText('LINESTRING(0 0,1 0)', 4326), 'SPHEROID["WGS 84",6378137,298.257223563]')`, nil},
	{"ST_LengthSpheroid", `ST_LengthSpheroid(ST_GeomFromText('LINESTRING(0 0,1 0)', 4326), 'SPHEROID["WGS 84",6378137,298.257223563]')`, nil},
	{"ST_LineFromText", `ST_AsText(ST_LineFromText('LINESTRING(0 0,1 1)'))`, "LINESTRING(0 0,1 1)"},
	{"ST_LineFromWKB", `ST_AsText(ST_LineFromWKB(ST_AsBinary(ST_GeomFromText('LINESTRING(0 0,1 1)'))))`, "LINESTRING(0 0,1 1)"},
	{"ST_LineInterpolatePoints", `ST_AsText(ST_LineInterpolatePoints(ST_GeomFromText('LINESTRING(0 0,10 0)'), 0.25))`, "MULTIPOINT((2.5 0),(5 0),(7.5 0),(10 0))"},
	{"ST_LineStringFromText", `ST_AsText(ST_LineStringFromText('LINESTRING(0 0,1 1)'))`, "LINESTRING(0 0,1 1)"},
	{"ST_LineSubstring", `ST_AsText(ST_LineSubstring(ST_GeomFromText('LINESTRING(0 0,10 0)'), 0.3, 0.7))`, "LINESTRING(3 0,7 0)"},
	{"ST_LongestLine", `ST_NPoints(ST_LongestLine(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('LINESTRING(2 -1,2 1)')))`, int64(2)},
	{"ST_MLineFromText", `ST_AsText(ST_MLineFromText('MULTILINESTRING((0 0,1 1))'))`, "MULTILINESTRING((0 0,1 1))"},
	{"ST_MPointFromText", `ST_AsText(ST_MPointFromText('MULTIPOINT((1 2),(3 4))'))`, "MULTIPOINT((1 2),(3 4))"},
	{"ST_MPolyFromText", `ST_AsText(ST_MPolyFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'))`, "MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))"},
	{"ST_MakeLine", `ST_AsText(ST_MakeLine(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(1 1)')))`, "LINESTRING(0 0,1 1)"},
	{"ST_MakePolygon", `ST_AsText(ST_MakePolygon(ST_GeomFromText('LINESTRING(0 0,1 0,1 1,0 0)')))`, "POLYGON((0 0,1 0,1 1,0 0))"},
	{"ST_MaxDistance", `ST_MaxDistance(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('LINESTRING(2 -1,2 1)'))`, nil},
	{"ST_MinimumBoundingCircle", `ST_Covers(ST_MinimumBoundingCircle(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0))')), ST_GeomFromText('POINT(4 4)'))`, int64(1)},
	{"ST_MinimumBoundingRadius", `ST_MinimumBoundingRadius(ST_GeomFromText('LINESTRING(0 0,4 0)'))`, nil},
	{"ST_Multi", `ST_AsText(ST_Multi(ST_GeomFromText('POINT(1 2)')))`, "MULTIPOINT((1 2))"},
	{"ST_NDims", `ST_NDims(ST_GeomFromText('POINT(1 2)'))`, int64(2)},
	{"ST_NRings", `ST_NRings(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))'))`, int64(2)},
	{"ST_NumInteriorRing", `ST_NumInteriorRing(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))'))`, int64(1)},
	{"ST_NumInteriorRings", `ST_NumInteriorRings(ST_GeomFromText('POLYGON((0 0,4 0,4 4,0 4,0 0),(1 1,2 1,2 2,1 2,1 1))'))`, int64(1)},
	{"ST_OrderingEquals", `ST_OrderingEquals(ST_GeomFromText('LINESTRING(0 0,1 1)'), ST_GeomFromText('LINESTRING(1 1,0 0)'))`, int64(0)},
	{"ST_Perimeter2D", `ST_Perimeter2D(ST_GeomFromText('POLYGON((0 0,2 0,2 2,0 2,0 0))'))`, int64(8)},
	{"ST_PointFromText", `ST_AsText(ST_PointFromText('POINT(1 2)'))`, "POINT(1 2)"},
	{"ST_PointFromWKB", `ST_AsText(ST_PointFromWKB(ST_AsBinary(ST_GeomFromText('POINT(1 2)'))))`, "POINT(1 2)"},
	{"ST_Points", `ST_AsText(ST_Points(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 0))')))`, "MULTIPOINT((0 0),(1 0),(1 1),(0 0))"},
	{"ST_PolyFromText", `ST_AsText(ST_PolyFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))`, "POLYGON((0 0,1 0,1 1,0 1,0 0))"},
	{"ST_PolyFromWKB", `ST_AsText(ST_PolyFromWKB(ST_AsBinary(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))))`, "POLYGON((0 0,1 0,1 1,0 1,0 0))"},
	{"ST_PolygonFromText", `ST_AsText(ST_PolygonFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))`, "POLYGON((0 0,1 0,1 1,0 1,0 0))"},
	{"ST_Project", `ST_X(ST_Project(ST_GeomFromText('POINT(0 0)'), 100, 1.5707963267948966))`, nil},
	{"ST_ReducePrecision", `ST_X(ST_ReducePrecision(ST_GeomFromText('POINT(1.234 5.678)'), 0.1))`, nil},
	{"ST_RelateMatch", `ST_RelateMatch('101202FFF', 'TTTTTTFFF')`, int64(1)},
	{"ST_RemovePoint", `ST_AsText(ST_RemovePoint(ST_GeomFromText('LINESTRING(0 0,1 1,2 2)'), 0))`, "LINESTRING(1 1,2 2)"},
	{"ST_Segmentize", `ST_NPoints(ST_Segmentize(ST_GeomFromText('LINESTRING(0 0,10 0)'), 4))`, int64(4)},
	{"ST_SetPoint", `ST_AsText(ST_SetPoint(ST_GeomFromText('LINESTRING(0 0,1 1)'), 0, ST_GeomFromText('POINT(9 9)')))`, "LINESTRING(9 9,1 1)"},
	{"ST_ShiftLongitude", `ST_AsText(ST_ShiftLongitude(ST_GeomFromText('POINT(-10 5)')))`, "POINT(350 5)"},
	{"ST_ShortestLine", `ST_AsText(ST_ShortestLine(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('LINESTRING(2 -1,2 1)')))`, "LINESTRING(0 0,2 0)"},
	// Bit trimming, not a grid: 1.23456789 at prec 2 keeps the mantissa bits
	// two decimals can distinguish, which lands on 1.234375.
	{"ST_QuantizeCoordinates", `ST_AsText(ST_QuantizeCoordinates(ST_GeomFromText('POINT(1.23456789 9.87654321)'), 2))`, "POINT(1.234375 9.875)"},
	{"ST_SnapToGrid", `ST_AsText(ST_SnapToGrid(ST_GeomFromText('POINT(1.23 4.57)'), 0.5))`, "POINT(1 4.5)"},
	{"ST_Subdivide", `ST_Area(ST_Subdivide(ST_Segmentize(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))'), 2), 8))`, nil},
	{"ST_SymmetricDifference", `ST_Area(ST_SymmetricDifference(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))'), ST_GeomFromText('POLYGON((5 5,15 5,15 15,5 15,5 5))')))`, nil},
	{"ST_TransScale", `ST_AsText(ST_TransScale(ST_GeomFromText('POINT(1 2)'), 1, 2, 3, 4))`, "POINT(6 16)"},
	{"ST_UnaryUnion", `ST_Area(ST_UnaryUnion(ST_GeomFromText('MULTIPOLYGON(((0 0,2 0,2 2,0 2,0 0)),((1 1,3 1,3 3,1 3,1 1)))')))`, nil},
	{"ST_XMax", `ST_XMax(ST_GeomFromText('LINESTRING(1 2,3 4)'))`, int64(3)},
	{"ST_XMin", `ST_XMin(ST_GeomFromText('LINESTRING(1 2,3 4)'))`, int64(1)},
	{"ST_YMax", `ST_YMax(ST_GeomFromText('LINESTRING(1 2,3 4)'))`, int64(4)},
	{"ST_YMin", `ST_YMin(ST_GeomFromText('LINESTRING(1 2,3 4)'))`, int64(2)},
	// ST_Extent is an aggregate, so it needs a table rather than one row.
	{"ST_Extent", `(SELECT ST_AsText(ST_Extent(g)) FROM (SELECT ST_GeomFromText('POINT(1 2)') AS g UNION ALL SELECT ST_GeomFromText('POINT(5 0)')))`, "POLYGON((1 0,1 2,5 2,5 0,1 0))"},
	// ST_3DExtent is the one aggregate returning TEXT, so it exercises the
	// binding's opt_text path. 2D rows contribute Z = 0, as in PostGIS.
	{"ST_3DExtent", `(SELECT ST_3DExtent(g) FROM (SELECT ST_GeomFromText('POINT(1 2)') AS g UNION ALL SELECT ST_GeomFromText('POINT(5 0)')))`, "BOX3D(1 0 0,5 2 0)"},
	// The routing aggregates: an edge table spelled as a subquery, the path
	// read back with json_extract. The 6-argument form (no reverse_cost).
	{"kenro_dijkstra", `(SELECT json_extract(kenro_dijkstra(id, source, target, cost, 1, 3), '$[2].agg_cost') FROM (SELECT 1 AS id, 1 AS source, 2 AS target, 1.1 AS cost UNION ALL SELECT 2, 2, 3, 0.7))`, 1.8},
	// …and the 7-argument form, where the trailing reverse_cost is the only
	// reason 3 → 1 exists at all.
	{"kenro_dijkstra_cost", `(SELECT kenro_dijkstra_cost(source, target, cost, 3, 1, rcost) FROM (SELECT 1 AS source, 2 AS target, 1.1 AS cost, 2.5 AS rcost UNION ALL SELECT 2, 3, 0.7, 4.0))`, 6.5},
	// Driving distance: a limit of 1.5 reaches node 2 but not node 3, so the
	// answer has two rows (the start's own row included).
	{"kenro_drivingdistance", `(SELECT json_array_length(kenro_drivingdistance(id, source, target, cost, 1, 1.5)) FROM (SELECT 1 AS id, 1 AS source, 2 AS target, 1.1 AS cost UNION ALL SELECT 2, 2, 3, 0.7))`, int64(2)},

	// --- the tail (functions::misc) ---
	{"ST_Box2dFromGeoHash", `ST_GeometryType(ST_Box2dFromGeoHash('xn76f'))`, "ST_Polygon"},
	{"ST_GeomFromGeoHash", `ST_GeometryType(ST_GeomFromGeoHash('xn76f'))`, "ST_Polygon"},
	{"ST_GeometricMedian", `ST_X(ST_GeometricMedian(ST_GeomFromText('MULTIPOINT((0 0),(4 0),(0 4),(4 4))')))`, nil},
	{"ST_LineCrossingDirection", `ST_LineCrossingDirection(ST_GeomFromText('LINESTRING(0 0,2 2)'), ST_GeomFromText('LINESTRING(0 2,2 0)'))`, int64(1)},
	{"ST_LineExtend", `ST_AsText(ST_LineExtend(ST_GeomFromText('LINESTRING(0 0,1 0)'), 1))`, "LINESTRING(0 0,1 0,2 0)"},
	{"ST_LineFromMultiPoint", `ST_AsText(ST_LineFromMultiPoint(ST_GeomFromText('MULTIPOINT((0 0),(1 1),(2 2))')))`, "LINESTRING(0 0,1 1,2 2)"},
	{"ST_LineStringFromWKB", `ST_AsText(ST_LineStringFromWKB(ST_AsBinary(ST_GeomFromText('LINESTRING(0 0,1 1)'))))`, "LINESTRING(0 0,1 1)"},
	{"ST_MLineFromWKB", `ST_AsText(ST_MLineFromWKB(ST_AsBinary(ST_GeomFromText('MULTILINESTRING((0 0,1 1))'))))`, "MULTILINESTRING((0 0,1 1))"},
	{"ST_MPointFromWKB", `ST_AsText(ST_MPointFromWKB(ST_AsBinary(ST_GeomFromText('MULTIPOINT((1 2),(3 4))'))))`, "MULTIPOINT((1 2),(3 4))"},
	{"ST_MPolyFromWKB", `ST_GeometryType(ST_MPolyFromWKB(ST_AsBinary(ST_GeomFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'))))`, "ST_MultiPolygon"},
	{"ST_MakeBox2D", `ST_AsText(ST_MakeBox2D(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(3 4)')))`, "POLYGON((0 0,0 4,3 4,3 0,0 0))"},
	{"ST_MemSize", `ST_MemSize(ST_GeomFromText('POINT(1 2)')) > 0`, int64(1)},
	{"ST_MultiLineFromWKB", `ST_AsText(ST_MultiLineFromWKB(ST_AsBinary(ST_GeomFromText('MULTILINESTRING((0 0,1 1))'))))`, "MULTILINESTRING((0 0,1 1))"},
	{"ST_MultiLineStringFromText", `ST_AsText(ST_MultiLineStringFromText('MULTILINESTRING((0 0,1 1))'))`, "MULTILINESTRING((0 0,1 1))"},
	{"ST_MultiPointFromText", `ST_AsText(ST_MultiPointFromText('MULTIPOINT((1 2),(3 4))'))`, "MULTIPOINT((1 2),(3 4))"},
	{"ST_MultiPointFromWKB", `ST_AsText(ST_MultiPointFromWKB(ST_AsBinary(ST_GeomFromText('MULTIPOINT((1 2))'))))`, "MULTIPOINT((1 2))"},
	{"ST_MultiPolyFromWKB", `ST_GeometryType(ST_MultiPolyFromWKB(ST_AsBinary(ST_GeomFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'))))`, "ST_MultiPolygon"},
	{"ST_MultiPolygonFromText", `ST_AsText(ST_MultiPolygonFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'))`, nil},
	{"ST_Normalize", `ST_IsPolygonCW(ST_Normalize(ST_GeomFromText('POLYGON((0 0,2 0,2 2,0 2,0 0))')))`, int64(1)},
	{"ST_PointFromGeoHash", `ST_AsText(ST_PointFromGeoHash('xn76f'))`, "POINT(139.68017578125 35.66162109375)"},
	{"ST_PointInsideCircle", `ST_PointInsideCircle(ST_GeomFromText('POINT(1 1)'), 0, 0, 2)`, int64(1)},
	{"ST_Polygon", `ST_SRID(ST_Polygon(ST_GeomFromText('LINESTRING(0 0,1 0,1 1,0 0)'), 4326))`, int64(4326)},
	{"ST_PolygonFromWKB", `ST_AsText(ST_PolygonFromWKB(ST_AsBinary(ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'))))`, "POLYGON((0 0,1 0,1 1,0 1,0 0))"},
	{"ST_RotateZ", `ST_AsText(ST_RotateZ(ST_GeomFromText('POINT(1 0)'), 1.5707963267948966))`, nil},
	{"ST_Summary", `ST_Summary(ST_GeomFromText('POINT(1 2)', 4326))`, nil},
	{"ST_WrapX", `ST_AsText(ST_WrapX(ST_GeomFromText('LINESTRING(-170 0,170 0)'), 0, 360))`, "LINESTRING(190 0,170 0)"},

	// --- 3D pass-through: a raw WKB blob, as a GDAL-written column would be ---
	{"ST_HasM", `ST_HasM(x'01e9030000000000000000f03f00000000000000400000000000000840')`, int64(0)},
	{"ST_HasZ", `ST_HasZ(x'01e9030000000000000000f03f00000000000000400000000000000840')`, int64(1)},
	{"ST_M", `ST_M(x'01e9030000000000000000f03f00000000000000400000000000000840') IS NULL`, int64(1)},
	{"ST_Z", `ST_Z(x'01e9030000000000000000f03f00000000000000400000000000000840')`, int64(3)},
	{"ST_ZMax", `ST_ZMax(x'01ea03000002000000000000000000000000000000000000000000000000002440000000000000f03f000000000000f03f0000000000003e40')`, int64(30)},
	{"ST_ZMin", `ST_ZMin(x'01ea03000002000000000000000000000000000000000000000000000000002440000000000000f03f000000000000f03f0000000000003e40')`, int64(10)},

	// --- GML 2/3 I/O ---
	{"ST_AsGML", `ST_AsGML(ST_GeomFromText('POINT(1 2)', 4326))`, nil},
	{"ST_GMLToSQL", `ST_AsText(ST_GMLToSQL('<gml:Point><gml:pos>1 2</gml:pos></gml:Point>'))`, "POINT(1 2)"},
	{"ST_GeomFromGML", `ST_AsText(ST_GeomFromGML('<gml:Point><gml:pos>1 2</gml:pos></gml:Point>'))`, "POINT(1 2)"},

	// --- surface collections: PostGIS's own POLYHEDRALSURFACE bytes ---
	{"ST_NumPatches", `ST_NumPatches(x'01f70300000100000001eb03000001000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000')`, int64(1)},
	{"ST_PatchN", `ST_AsText(ST_PatchN(x'01f70300000100000001eb03000001000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000', 1))`, "POLYGON((0 0,0 1,1 1,1 0,0 0))"},
	{"kenro_gpkg_extension_required", `kenro_gpkg_extension_required(x'01f70300000100000001eb03000001000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000f03f000000000000f03f0000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000')`, "gpkg_geom_POLYHEDRALSURFACE"},
}

func TestSmoke(t *testing.T) {
	db := open(t)
	for _, c := range smokeCases {
		t.Run(c.fn, func(t *testing.T) {
			got := value(t, db, c.sql)
			if c.want == nil {
				if got == nil {
					t.Fatalf("%s: got NULL", c.sql)
				}
				return
			}
			if f, ok := c.want.(float64); ok {
				g, ok := got.(float64)
				if !ok {
					t.Fatalf("%s: got %T(%v), want float64", c.sql, got, got)
				}
				if math.Abs(g-f) > 1e-9 {
					t.Fatalf("%s: got %v, want %v", c.sql, g, f)
				}
				return
			}
			if fmt.Sprint(got) != fmt.Sprint(c.want) {
				t.Fatalf("%s: got %v (%T), want %v", c.sql, got, got, c.want)
			}
		})
	}
}

// TestSmokeCoversTheManifest fails when a function is added to kenro but not
// exercised here — the manifest is the source of truth on both sides.
func TestSmokeCoversTheManifest(t *testing.T) {
	covered := map[string]bool{}
	for _, c := range smokeCases {
		covered[c.fn] = true
	}
	names := manifestNames(t)
	for _, name := range names {
		if !covered[name] {
			t.Errorf("%s is registered but has no smoke case", name)
		}
	}
	for name := range covered {
		if !slices.Contains(names, name) {
			t.Errorf("%s has a smoke case but is not in the manifest", name)
		}
	}
}

func manifestNames(t *testing.T) []string {
	t.Helper()
	ctx := context.Background()
	rt, err := newRuntime(ctx, defaultModule())
	if err != nil {
		t.Fatalf("newRuntime: %v", err)
	}
	defer rt.close(ctx)
	m, err := (&binding{rt: rt, cfg: &config{}}).manifest(ctx)
	if err != nil {
		t.Fatalf("manifest: %v", err)
	}
	var names []string
	for _, e := range m.Functions {
		if !slices.Contains(names, e.SQLName) {
			names = append(names, e.SQLName)
		}
	}
	for _, e := range m.Aggregates {
		if !slices.Contains(names, e.SQLName) {
			names = append(names, e.SQLName)
		}
	}
	return names
}

func TestNullStrictness(t *testing.T) {
	db := open(t)
	for _, q := range []string{
		`ST_Area(NULL)`,
		`ST_Intersects(NULL, ` + poly + `)`,
		`ST_Intersects(` + poly + `, NULL)`,
		`ST_GeomFromText(NULL, 4326)`,
		`ST_Buffer(` + pt + `, NULL)`,
	} {
		if got := value(t, db, q); got != nil {
			t.Errorf("%s: got %v, want NULL", q, got)
		}
	}
}

func TestErrorsCarryKenroPrefix(t *testing.T) {
	db := open(t)
	for _, tc := range []struct{ query, want string }{
		{`ST_GeomFromText('NOT A GEOMETRY')`, "kenro: invalid WKT"},
		{`ST_Area('a string')`, "did you mean ST_GeomFromText?"},
		{`ST_Collect(` + pt + `)`, "is not implemented in kenro"},
		{`ST_Area(` + pt + `, 1)`, "takes 1 argument(s), got 2"},
	} {
		var v any
		err := db.QueryRow("SELECT " + tc.query).Scan(&v)
		if err == nil {
			t.Errorf("%s: expected an error, got %v", tc.query, v)
			continue
		}
		if !strings.Contains(err.Error(), tc.want) {
			t.Errorf("%s: error %q does not contain %q", tc.query, err, tc.want)
		}
	}
}

// ST_Affine's 3D form is the widest call in the manifest — 14 wasm parameters
// against a scratch stack sized for 12 — and the smoke table only covers one
// arity per name, so it gets its own test. Both expectations were measured on
// PostGIS 3.5.
func TestAffine3DThroughTheWasmBinding(t *testing.T) {
	db := open(t)
	var wkt string
	err := db.QueryRow(`SELECT ST_AsText(ST_Affine(ST_GeomFromText('POINT(1 2)'),
	                        1,2,3, 4,5,6, 7,8,9, 10,20,30))`).Scan(&wkt)
	if err != nil {
		t.Fatalf("query: %v", err)
	}
	// A 2D geometry stays 2D: z is taken as 0 and the z' row is discarded.
	if wkt != "POINT(15 34)" {
		t.Errorf("ST_Affine 3D form = %q, want POINT(15 34)", wkt)
	}
	// The 2D form still works alongside it, on the same connection.
	if err := db.QueryRow(`SELECT ST_AsText(ST_Affine(ST_GeomFromText('LINESTRING(1 2,3 4)'),
	                           2,0,0,2, 10,20))`).Scan(&wkt); err != nil {
		t.Fatalf("query: %v", err)
	}
	if wkt != "LINESTRING(12 24,16 28)" {
		t.Errorf("ST_Affine 2D form = %q, want LINESTRING(12 24,16 28)", wkt)
	}
}

// The tier gate. Every assertion here uses only functions the manifest lists
// unconditionally, so this test is valid against a module built at *any*
// feature tier — which is what lets CI run it over `minimal` and `standard`
// builds, where the rest of this file (ST_Buffer, ST_Union, …) legitimately
// sees stub errors instead.
//
// It exists because three separate `overlay` cfgs had been copy-pasted onto
// things that do not depend on overlay, and CI only ever built `full`:
// kenro-abi did not compile below `full` at all, `k_agg_extent_step` went
// missing so ST_Extent registered without a way to step it, and a stray cfg
// above a section comment took `k_stNumPatches` with it — which made a
// standard-tier module fail registration outright.
func TestEveryTierRegistersAndRuns(t *testing.T) {
	db := open(t) // registration verifies the manifest against real exports

	// Surface collections: ungated, and the one a stray cfg removed.
	// POLYHEDRALSURFACE Z with one patch, as PostGIS encodes it.
	const phs = `x'01f70300000100000001eb0300000100000005000000` +
		`0000000000000000000000000000000000000000000000000000000000000000` +
		`0000000000000000000000000000f03f0000000000000000` +
		`000000000000f03f000000000000f03f0000000000000000` +
		`000000000000f03f00000000000000000000000000000000` +
		`0000000000000000000000000000000000000000000000000000000000000000'`
	if got := value(t, db, `ST_NumPatches(`+phs+`)`); got != int64(1) {
		t.Errorf("ST_NumPatches = %v, want 1", got)
	}
	if got := value(t, db, `kenro_gpkg_extension_required(`+phs+`)`); got != "gpkg_geom_POLYHEDRALSURFACE" {
		t.Errorf("kenro_gpkg_extension_required = %v", got)
	}

	// Both aggregates are ungated, and each needs its own step export.
	const rows = `(SELECT ST_GeomFromText('POINT(1 2)') AS g UNION ALL SELECT ST_GeomFromText('POINT(5 0)'))`
	if got := value(t, db, `(SELECT ST_AsText(ST_Extent(g)) FROM `+rows+`)`); got != "POLYGON((1 0,1 2,5 2,5 0,1 0))" {
		t.Errorf("ST_Extent = %v", got)
	}
	if got := value(t, db, `(SELECT ST_3DExtent(g) FROM `+rows+`)`); got != "BOX3D(1 0 0,5 2 0)" {
		t.Errorf("ST_3DExtent = %v", got)
	}

	// And the coordinate rewriter, which is ungated too.
	if got := value(t, db, `ST_AsText(ST_Affine(ST_GeomFromText('POINT(1 2)'), 1,2,3, 4,5,6, 7,8,9, 10,20,30))`); got != "POINT(15 34)" {
		t.Errorf("ST_Affine 3D form = %v", got)
	}
}

func TestAggregateOverEmptySetIsNull(t *testing.T) {
	db := open(t)
	var v any
	if err := db.QueryRow(`SELECT ST_Union(g) FROM (SELECT ` + pt + ` AS g WHERE 0)`).Scan(&v); err != nil {
		t.Fatalf("empty aggregate: %v", err)
	}
	if v != nil {
		t.Fatalf("got %v, want NULL", v)
	}
}

func TestAggregateSkipsNullRows(t *testing.T) {
	db := open(t)
	got := value(t, db, `(SELECT ST_Area(ST_Union(g)) FROM (SELECT `+poly+` AS g UNION ALL SELECT NULL))`)
	if f, ok := got.(float64); !ok || math.Abs(f-6) > 1e-9 {
		t.Fatalf("got %v, want 6", got)
	}
}

// The scalar/aggregate collision is a driver limitation, not a kenro one:
// whichever form is not registered must fail loudly and name the way out.
func TestUnionScalarFormIsALoudError(t *testing.T) {
	db := open(t)
	var v any
	err := db.QueryRow(`SELECT ST_Union(` + poly + `, ` + poly2 + `)`).Scan(&v)
	if err == nil {
		t.Fatalf("expected an error for the scalar form under UnionAggregate, got %v", v)
	}
	if !strings.Contains(err.Error(), "argument") {
		t.Fatalf("unhelpful error: %v", err)
	}
}

func TestConcurrentQueries(t *testing.T) {
	db := open(t)
	db.SetMaxOpenConns(8)
	errs := make(chan error, 64)
	for range 64 {
		go func() {
			var area float64
			errs <- db.QueryRow(`SELECT ST_Area(ST_Buffer(` + pt + `, 1))`).Scan(&area)
		}()
	}
	for i := range 64 {
		if err := <-errs; err != nil {
			t.Fatalf("concurrent query %d: %v", i, err)
		}
	}
}

// Kind::BlobOrText — the box accessors' TEXT argument. The smoke table calls
// each of the six with a geometry, so the string path needs its own test: it
// is the one kind where the Go marshaller has to accept a `string` for an
// argument that crosses as bytes. Every expectation was measured on PostGIS
// 3.5 (`SELECT ST_XMin('BOX3D(1 2 3,4 5 6)')` and friends).
func TestBoxTextThroughTheWasmBinding(t *testing.T) {
	db := open(t)
	const box = `'BOX3D(1 2 3,4 5 6)'`
	for _, tc := range []struct {
		query string
		want  float64
	}{
		{`ST_XMin(` + box + `)`, 1},
		{`ST_YMin(` + box + `)`, 2},
		{`ST_ZMin(` + box + `)`, 3},
		{`ST_XMax(` + box + `)`, 4},
		{`ST_YMax(` + box + `)`, 5},
		{`ST_ZMax(` + box + `)`, 6},
		{`ST_MinX(` + box + `)`, 1},
		{`ST_MaxY(` + box + `)`, 5},
		// PostGIS refuses the 2D spelling in its box3d parser; kenro takes it
		// because ST_Extent renders it and SQLite has no cast to route it
		// through. Z is 0, as it is for `BOX3D(1 2,4 5)` on PostGIS.
		{`ST_XMin('BOX(1 2,4 5)')`, 1},
		{`ST_ZMax('BOX(1 2,4 5)')`, 0},
		// ST_3DExtent's own output, read straight back.
		{`ST_XMax(ST_3DExtent(ST_GeomFromText('LINESTRING(1 2,7 8)')))`, 7},
	} {
		var got float64
		if err := db.QueryRow("SELECT " + tc.query).Scan(&got); err != nil {
			t.Errorf("%s: %v", tc.query, err)
			continue
		}
		if got != tc.want {
			t.Errorf("%s: got %v, want %v", tc.query, got, tc.want)
		}
	}
	// Unparseable text is an error that names both ways in — the help the
	// blob-only path used to give as "did you mean ST_GeomFromText?".
	var v any
	err := db.QueryRow(`SELECT ST_MinX('LINESTRING(0 0,1 1)')`).Scan(&v)
	if err == nil {
		t.Fatalf("expected an error, got %v", v)
	}
	for _, want := range []string{"BOX3D(minx miny minz", "ST_GeomFromText"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error %q does not contain %q", err, want)
		}
	}
}