#115: Formalized acceptance tests. #119

Merged
art merged 5 commits from issue-115-acceptance-tests into main 2026-08-15 09:32:12 +00:00
Owner

#115: Formalized acceptance tests.

Closes #115.

Review round 2 (2026-08-15)

Two commits on top of the original submission: the mechanism's partial-database
contract is now enforced by the machinery rather than by SQL style, and the rule that
future checks go through this mechanism is written where the work actually gets
directed.

The finding that mattered

The claim that a test skips rather than fails on a partially-populated database held
only for tests that named a partition table directly. Measured against the live DB,
the four shapes in the tree behaved three different ways for the same situation:

test shape on absent data outcome
map_cover_13_se (names a partition) 42P01 SKIP
string_agg(…) = '…' NULL SKIP (by luck)
count(*) FILTER (…) > 0 false FAIL
pg_inherits catalogue query 0 rows FAIL

-acceptance-only conf/semap.yaml against a database holding only Iceland would have
reported failures for Sweden's station ladder and Poland's ferry partition. With
#121–#125 about to add forty more tests, whichever shape gets copied would decide the
behaviour.

requires: is the fix: a test declares the tables it needs, with <cc> expanding
to the owning layer's country code, so one line in a country config means that
country's own partition. Absent table → skipped, and named in the log. Startup
validation rejects a requires: that is not a table name, or a <cc> on a test with
no country to substitute. This also replaces the Tornio test's hand-written
CASE WHEN NOT EXISTS (…) THEN NULL guard with
requires: [map_roads_14_se, map_roads_14_fi].

The catalogue queries can't use it — the parent tables exist from the importer's first
run, and it's the children that vary — so they aggregate instead: bool_and over an
empty set is NULL, which is already "not applicable". They now name the offending
partitions through a FILTER column, which the per-row form gave for free.

The rest of the review

  • A tally line ends every run (13 test(s): 13 passed, 0 failed, 0 skipped, 0 not run). An all-skipped run and an all-passed run both exited 0 and looked identical.
    In -acceptance-only, zero passes is now itself a non-zero exit — that mode exists
    to produce a verdict, and "everything skipped" is not one.
  • make acceptance / make acceptance-all. AGENTS.md says everything runs through
    the Makefile; this was the one thing that didn't. make test now runs the importer's
    tests too — it had been mapsrv-only since before this branch.
  • The gist-index test now checks for gist. It asserted only that some valid index
    had geom as a key column, so a btree on a geometry column would have passed.
  • The verdict logic is a pure function (acceptanceVerdict) with the pgx
    row-draining split out, so all ten branches have unit tests — including that a
    failure reports the first failing row, not the first row.
  • R83 no longer passes vacuously on an empty places table; formatAcceptanceDetail no
    longer sizes a slice with len(values)-1; gofmt.

Making the mechanism the default path

The rule existed only in docs/importer-cli.md — a flag reference nobody reads before
verifying — while everything that directs the work still described the hand-rolled path:
AGENTS.md's first convention, work-issue's step 5 (the file a subagent executes),
AGENTS.md's Tornio paragraph, and docs/country-requirements.md's 72 *Check:* lines,
one of which was literally the query R119's test replaces.

  • AGENTS.md gains a convention beside verify-before-committing: a verification worth
    doing twice is an acceptance test, not a query you retype. Explore in psql freely; the
    check that decided the work is correct gets written down. A PR that verifies a layer by
    pasting SQL into its body, when that SQL could have been a test, isn't finished — and a
    check that genuinely can't be expressed as one is a gap to file, not a licence to go
    back to psql.
  • work-issue step 5 gets the operational form: turn the checks that mattered into
    tests in the same commit, run make acceptance, paste the tally into the note instead
    of ad-hoc SQL output. work-issues expects to see that tally when verifying a
    worker's self-report.
  • docs/country-requirements.md gains a preamble saying the *Check:* lines are
    migrating into the importer and that a line still written as bare SQL is a candidate,
    not a decision to keep it manual. Eleven requirements now name their test (R6, R14,
    R22, R35, R41, R46, R75, R83, R85, R119, R123–R126); where only half is covered, the
    line says which half and what the other half needs.
  • docs/importer-cli.md rewritten around the mechanism rather than the flags.
    README documents make acceptance (code vs. data). architecture.md gains
    acceptance.go. country-finland.md notes the #17 criterion is now literally a test.

Verification

  • make build, make test, gofmt, go vet clean.
  • All five configs live: 57 test runs, 0 failures, same numbers as before (Sweden's
    R35 carve scan 18.2 s, Stockholm z5 tile 1 841 744 bytes). make acceptance correctly
    defers the one 100s test and finishes in ~7 s.
  • Throwaway configs exercised each path: requires: on a missing partition skips, a
    catalogue query over a nonexistent layer skips, a nothing-verified review run exits 1
    with the reason, a 1s test against pg_sleep(30) fails at 1.001 s and leaves no
    backend behind (checked pg_stat_activity with 26 s of sleep left), a mistyped column
    still fails rather than skipping, and <cc> on a config-level test is rejected at
    startup.
  • A real -layer country_mask conf/ismap.yaml import ran its acceptance test after the
    swap and printed its tally.

Original submission


What this is

The importer's first built-in acceptance-test machinery: layers and shared-layer
contracts can declare acceptance_tests: in config — a sentence describing what is
being tested (citing the R-number from docs/country-requirements.md where it
restates one), a SQL query whose first column is the pass/fail boolean, and a
timeout from the issue's fixed ladder {1, 10, 100, 1000} seconds. The 1s and 10s
tests run on every import of the layer (and after each -regeneralize window); the
100s and 1000s ones run with -acceptance all. -acceptance-only runs the
configured tests against the live database and exits — no fetching, no staging, no
writes — which is the review path: a review (or a scheduled re-check) now runs the
importer's own tests instead of hand-rolled SQL. A third home, the config-level
key, carries "global" tests not tied to any layer (the review comment's ask): the
shared index-coverage and MVT-size checks live there, once, in conf/common.yaml,
and run on every import and in every review pass.

Where the tests live

  • conf/common.yaml — on the shared-layer contracts (shared_layers: entries),
    so a cross-country invariant is declared once. The contract's tests query the
    shared parent tables and run on any country's import of that layer.
  • conf/<cc>map.yaml — on a country's own layers, querying its own partitions.

The first tests encode the checks the recent reviews actually hand-rolled:

owner test what it encodes
shared cover R22: z3 classes are one of the level-1 rollup tier (+ glacier) #13's silent rollup gap
shared cover R119: no empty partition at any zoom R119 / #14's partition audit
shared country_borders R119/R14: no empty border partition #45
shared waterways R46: no segment lacks an importance the shared 1–5 ladder
shared roads R126: Tornio–Haparanda, both sides trunk E4 #17's acceptance criterion, R62
shared places R85: z4's last name standing == z14's top rank, per country #71/#77/#78/#79/#80
shared places R83: rank unique per country the budget's total order
config (global) every map_* table's geom column has a valid gist index the MVT functions' bounding-box lookups
config (global) the densest z5 map tile (Stockholm) stays under 8 MB measured ~1.8 MB; catches a layer running away
is country_mask R41: the national mask is valid the load-bearing geometry
is places R85/R86: top five are the five largest settlements #78 — a cave ranked above Reykjavík
se cover R35: 0 invalid geometries at maxz (100s bucket) #14/#116's carve, ~20 s live
se cover R125: wetland survives to z3 #117
se places R85: top five are Sweden's five biggest cities #29 — Malmö at 6 is the recorded near-miss, deliberately not asserted
se stations R75: ladder has no gap #68/#81 shape
pl water_points R6: the spring/waterfall partition is populated #85's OT_OIPR_P finding
pl ferries R6: the ferry partition is populated #85's OT_SKPP_L finding
pl stations R75: ladder has no gap #68
pl places R85: top five are Poland's five biggest cities #79 — the alphabetical ladder
fi stations R75: ladder has no gap (112 principals) #81
fi places R85: top five are Finland's five biggest cities, register casing #80 — FORSSA…HELSINKI

Design decisions (the issue's open questions)

  • One key per layer/contract (acceptance_tests:), plus a config-level key
    for tests not tied to any layer (the review comment's "global tests"): a test
    is about the thing it names, and the key's home (shared contract, country
    layer, or config top level) says which tables it may touch. Shared and
    config-level tests live in conf/common.yaml and are inherited by every country
    config through the existing include: merge — no new inheritance machinery.
  • 1s and 10s tests run by default. The issue leaves "1 (and maybe 10)" open;
    10s is where the meaningful per-import checks live (a partition listing, a
    mid-size validity scan), and ten seconds per layer is cheap next to the import
    itself. 100s/1000s need -acceptance all.
  • Fail semantics: run everything, then exit non-zero. Tests run after the
    layer's atomic swap, so the data is live either way; a failing test fails the
    whole run at the end with a summary naming every failed test. A partial DB never
    fails on a test for data that is not there: "relation does not exist" (42P01)
    and an all-NULL first column are logged as SKIPs. The all-NULL form is how the
    Tornio test says "the other country has not imported yet".
  • Timeout enforcement is a context deadline per test; pgx cancels the query on
    the server when it fires, so a runaway test cannot pin a worker.
  • -regeneralize runs the fast tests too after each window — it is the
    iteration loop for generalization parameters, and the tests are the loop's
    verification.

Verification

  • go test ./... in importer/ and mapsrv/; make build.
  • New unit tests: YAML parse (layer + contract), startup validation (ladder,
    names, uniqueness), the fast/all timeout policy, the failure summary format.
  • TestRealConfigsValidate now validates the acceptance_tests blocks of every
    real config.
  • Every config's full suite run live against the maps DB
    (-acceptance-only -acceptance all for all five configs): 47 test runs, 0
    failures. Sweden's R35 carve scan measured 18.5 s (comfortably inside its 100s
    bucket).
  • The import hook verified live: a real single-layer import
    (-layer country_mask conf/ismap.yaml) ran its acceptance test after the swap.
  • Failure, skip (42P01) and not-applicable (NULL) paths exercised with throwaway
    configs; failure exits non-zero with the summary.
  • A genuine finding surfaced by writing the tests: Iceland's map_cover_13_is
    contains 1 invalid geometry
    (an estuary with a ring self-intersection near
    22.4°W/64.6°N). It is filed as #120 and is exactly the kind
    of thing the -acceptance-only review pass now catches on every run.

Not done

  • The full R126 endpoint-pairing query from docs/country-finland.md (every border
    crossing, not just Tornio) is not yet a test; the Tornio form is the issue #17
    criterion and stays the cheap canary. The pairing query is a 1000s-bucket
    candidate when someone wants it.
  • Per the issue's review comment, the rest of the requirement-to-test work is
    chopped into follow-up issues rather than grown here: #121 (Iceland), #122
    (Sweden), #123 (Poland), #124 (Finland) and #125 (global/MVT) carry the
    per-country and config-level candidates already listed in
    docs/country-requirements.md. The machinery is in place and the pattern is
    established; adding tests is per-issue work now, which is the point.
# #115: Formalized acceptance tests. Closes #115. ## Review round 2 (2026-08-15) Two commits on top of the original submission: the mechanism's partial-database contract is now enforced by the machinery rather than by SQL style, and the rule that future checks go through this mechanism is written where the work actually gets directed. ### The finding that mattered The claim that a test skips rather than fails on a partially-populated database held only for tests that named a partition table directly. Measured against the live DB, the four shapes in the tree behaved three different ways for the same situation: | test shape | on absent data | outcome | |---|---|---| | `map_cover_13_se` (names a partition) | 42P01 | SKIP | | `string_agg(…) = '…'` | NULL | SKIP (by luck) | | `count(*) FILTER (…) > 0` | `false` | **FAIL** | | `pg_inherits` catalogue query | 0 rows | **FAIL** | `-acceptance-only conf/semap.yaml` against a database holding only Iceland would have reported failures for Sweden's station ladder and Poland's ferry partition. With #121–#125 about to add forty more tests, whichever shape gets copied would decide the behaviour. **`requires:`** is the fix: a test declares the tables it needs, with `<cc>` expanding to the owning layer's country code, so one line in a country config means that country's own partition. Absent table → skipped, and named in the log. Startup validation rejects a `requires:` that is not a table name, or a `<cc>` on a test with no country to substitute. This also replaces the Tornio test's hand-written `CASE WHEN NOT EXISTS (…) THEN NULL` guard with `requires: [map_roads_14_se, map_roads_14_fi]`. The catalogue queries can't use it — the parent tables exist from the importer's first run, and it's the children that vary — so they aggregate instead: `bool_and` over an empty set is NULL, which is already "not applicable". They now name the offending partitions through a `FILTER` column, which the per-row form gave for free. ### The rest of the review - **A tally line ends every run** (`13 test(s): 13 passed, 0 failed, 0 skipped, 0 not run`). An all-skipped run and an all-passed run both exited 0 and looked identical. In `-acceptance-only`, zero passes is now itself a non-zero exit — that mode exists to produce a verdict, and "everything skipped" is not one. - **`make acceptance` / `make acceptance-all`.** AGENTS.md says everything runs through the Makefile; this was the one thing that didn't. `make test` now runs the importer's tests too — it had been mapsrv-only since before this branch. - **The gist-index test now checks for gist.** It asserted only that some valid index had `geom` as a key column, so a btree on a geometry column would have passed. - **The verdict logic is a pure function** (`acceptanceVerdict`) with the pgx row-draining split out, so all ten branches have unit tests — including that a failure reports the first *failing* row, not the first row. - R83 no longer passes vacuously on an empty places table; `formatAcceptanceDetail` no longer sizes a slice with `len(values)-1`; gofmt. ### Making the mechanism the default path The rule existed only in `docs/importer-cli.md` — a flag reference nobody reads *before* verifying — while everything that directs the work still described the hand-rolled path: AGENTS.md's first convention, `work-issue`'s step 5 (the file a subagent executes), AGENTS.md's Tornio paragraph, and `docs/country-requirements.md`'s 72 `*Check:*` lines, one of which was literally the query R119's test replaces. - **AGENTS.md** gains a convention beside verify-before-committing: a verification worth doing twice is an acceptance test, not a query you retype. Explore in psql freely; the check that decided the work is correct gets written down. A PR that verifies a layer by pasting SQL into its body, when that SQL could have been a test, isn't finished — and a check that genuinely can't be expressed as one is a gap to file, not a licence to go back to psql. - **`work-issue` step 5** gets the operational form: turn the checks that mattered into tests in the same commit, run `make acceptance`, paste the tally into the note instead of ad-hoc SQL output. **`work-issues`** expects to see that tally when verifying a worker's self-report. - **`docs/country-requirements.md`** gains a preamble saying the `*Check:*` lines are migrating into the importer and that a line still written as bare SQL is a candidate, not a decision to keep it manual. Eleven requirements now name their test (R6, R14, R22, R35, R41, R46, R75, R83, R85, R119, R123–R126); where only half is covered, the line says which half and what the other half needs. - **`docs/importer-cli.md`** rewritten around the mechanism rather than the flags. **README** documents `make acceptance` (code vs. data). **architecture.md** gains `acceptance.go`. **country-finland.md** notes the #17 criterion is now literally a test. ### Verification - `make build`, `make test`, `gofmt`, `go vet` clean. - All five configs live: **57 test runs, 0 failures**, same numbers as before (Sweden's R35 carve scan 18.2 s, Stockholm z5 tile 1 841 744 bytes). `make acceptance` correctly defers the one 100s test and finishes in ~7 s. - Throwaway configs exercised each path: `requires:` on a missing partition skips, a catalogue query over a nonexistent layer skips, a nothing-verified review run exits 1 with the reason, a 1s test against `pg_sleep(30)` fails at 1.001 s and leaves no backend behind (checked `pg_stat_activity` with 26 s of sleep left), a mistyped column still fails rather than skipping, and `<cc>` on a config-level test is rejected at startup. - A real `-layer country_mask conf/ismap.yaml` import ran its acceptance test after the swap and printed its tally. --- ## Original submission --- ## What this is The importer's first built-in acceptance-test machinery: layers and shared-layer contracts can declare `acceptance_tests:` in config — a sentence describing what is being tested (citing the R-number from docs/country-requirements.md where it restates one), a SQL query whose first column is the pass/fail boolean, and a timeout from the issue's fixed ladder {1, 10, 100, 1000} seconds. The 1s and 10s tests run on every import of the layer (and after each -regeneralize window); the 100s and 1000s ones run with `-acceptance all`. `-acceptance-only` runs the configured tests against the live database and exits — no fetching, no staging, no writes — which is the review path: a review (or a scheduled re-check) now runs the importer's own tests instead of hand-rolled SQL. A third home, the config-level key, carries "global" tests not tied to any layer (the review comment's ask): the shared index-coverage and MVT-size checks live there, once, in conf/common.yaml, and run on every import and in every review pass. ## Where the tests live - `conf/common.yaml` — on the shared-layer contracts (`shared_layers:` entries), so a cross-country invariant is declared once. The contract's tests query the shared parent tables and run on any country's import of that layer. - `conf/<cc>map.yaml` — on a country's own layers, querying its own partitions. The first tests encode the checks the recent reviews actually hand-rolled: | owner | test | what it encodes | |---|---|---| | shared `cover` | R22: z3 classes are one of the level-1 rollup tier (+ glacier) | #13's silent rollup gap | | shared `cover` | R119: no empty partition at any zoom | R119 / #14's partition audit | | shared `country_borders` | R119/R14: no empty border partition | #45 | | shared `waterways` | R46: no segment lacks an importance | the shared 1–5 ladder | | shared `roads` | R126: Tornio–Haparanda, both sides trunk E4 | #17's acceptance criterion, R62 | | shared `places` | R85: z4's last name standing == z14's top rank, per country | #71/#77/#78/#79/#80 | | shared `places` | R83: rank unique per country | the budget's total order | | **config (global)** | every `map_*` table's geom column has a valid gist index | the MVT functions' bounding-box lookups | | **config (global)** | the densest z5 map tile (Stockholm) stays under 8 MB | measured ~1.8 MB; catches a layer running away | | is `country_mask` | R41: the national mask is valid | the load-bearing geometry | | is `places` | R85/R86: top five are the five largest settlements | #78 — a cave ranked above Reykjavík | | se `cover` | R35: 0 invalid geometries at maxz (100s bucket) | #14/#116's carve, ~20 s live | | se `cover` | R125: wetland survives to z3 | #117 | | se `places` | R85: top five are Sweden's five biggest cities | #29 — Malmö at 6 is the recorded near-miss, deliberately not asserted | | se `stations` | R75: ladder has no gap | #68/#81 shape | | pl `water_points` | R6: the spring/waterfall partition is populated | #85's OT_OIPR_P finding | | pl `ferries` | R6: the ferry partition is populated | #85's OT_SKPP_L finding | | pl `stations` | R75: ladder has no gap | #68 | | pl `places` | R85: top five are Poland's five biggest cities | #79 — the alphabetical ladder | | fi `stations` | R75: ladder has no gap (112 principals) | #81 | | fi `places` | R85: top five are Finland's five biggest cities, register casing | #80 — FORSSA…HELSINKI | ## Design decisions (the issue's open questions) - **One key per layer/contract** (`acceptance_tests:`), plus a config-level key for tests not tied to any layer (the review comment's "global tests"): a test is about the thing it names, and the key's home (shared contract, country layer, or config top level) says which tables it may touch. Shared and config-level tests live in conf/common.yaml and are inherited by every country config through the existing `include:` merge — no new inheritance machinery. - **1s and 10s tests run by default.** The issue leaves "1 (and maybe 10)" open; 10s is where the meaningful per-import checks live (a partition listing, a mid-size validity scan), and ten seconds per layer is cheap next to the import itself. 100s/1000s need `-acceptance all`. - **Fail semantics: run everything, then exit non-zero.** Tests run after the layer's atomic swap, so the data is live either way; a failing test fails the whole run at the end with a summary naming every failed test. A partial DB never fails on a test for data that is not there: "relation does not exist" (42P01) and an all-NULL first column are logged as SKIPs. The all-NULL form is how the Tornio test says "the other country has not imported yet". - **Timeout enforcement** is a context deadline per test; pgx cancels the query on the server when it fires, so a runaway test cannot pin a worker. - **-regeneralize runs the fast tests too** after each window — it is the iteration loop for generalization parameters, and the tests are the loop's verification. ## Verification - `go test ./...` in importer/ and mapsrv/; `make build`. - New unit tests: YAML parse (layer + contract), startup validation (ladder, names, uniqueness), the fast/all timeout policy, the failure summary format. - `TestRealConfigsValidate` now validates the acceptance_tests blocks of every real config. - Every config's full suite run live against the maps DB (`-acceptance-only -acceptance all` for all five configs): 47 test runs, 0 failures. Sweden's R35 carve scan measured 18.5 s (comfortably inside its 100s bucket). - The import hook verified live: a real single-layer import (`-layer country_mask conf/ismap.yaml`) ran its acceptance test after the swap. - Failure, skip (42P01) and not-applicable (NULL) paths exercised with throwaway configs; failure exits non-zero with the summary. - A genuine finding surfaced by writing the tests: **Iceland's `map_cover_13_is` contains 1 invalid geometry** (an `estuary` with a ring self-intersection near 22.4°W/64.6°N). It is filed as #120 and is exactly the kind of thing the -acceptance-only review pass now catches on every run. ## Not done - The full R126 endpoint-pairing query from docs/country-finland.md (every border crossing, not just Tornio) is not yet a test; the Tornio form is the issue #17 criterion and stays the cheap canary. The pairing query is a 1000s-bucket candidate when someone wants it. - Per the issue's review comment, the rest of the requirement-to-test work is chopped into follow-up issues rather than grown here: #121 (Iceland), #122 (Sweden), #123 (Poland), #124 (Finland) and #125 (global/MVT) carry the per-country and config-level candidates already listed in docs/country-requirements.md. The machinery is in place and the pattern is established; adding tests is per-issue work now, which is the point.
Layers and shared-layer contracts can now declare `acceptance_tests:` in config:
a name (a sentence describing what is tested, citing the R-number from
docs/country-requirements.md where it restates one), a SQL query whose first
column is the pass/fail boolean, and a timeout from the issue's fixed ladder
{1, 10, 100, 1000} seconds. The 1s and 10s tests run after every import of the
layer (and after each -regeneralize window); 100s and 1000s tests need
`-acceptance all`. `-acceptance-only` runs the configured tests against the live
database and exits — no fetching, no import, no writes — which is the review path
the issue asks for: a review runs the importer's own tests instead of hand-rolled
SQL.

Where tests live: the shared contracts in conf/common.yaml (cross-country
invariants on the shared parent tables, run on any country's import of the
layer) and the per-country configs (a country's own partitions). The first set
encodes the checks the recent reviews actually hand-rolled: Sweden's R35 carve
validity (100s bucket, ~20s live) and z3 wetland, all four countries' R85 top-five
place ranks, the R75 station ladders, Poland's R6 ferry/water-point partitions,
and the shared contracts' R22/R83/R119/R126 checks (including the Tornio-Haparanda
border test from issue #17).

Decisions on the issue's open questions: 10s tests run by default (the issue
leaves "1 (and maybe 10)" open; 10s is where the meaningful per-import checks
live); a failing test does not roll back the layer (tests run after the atomic
swap) but fails the whole run at the end with a summary naming every failure; a
partial DB cannot fail on missing data — "relation does not exist" and an all-NULL
verdict are logged as skips, the NULL form being how the Tornio test says "the
other country has not imported yet". Timeout enforcement is a per-test context
deadline; pgx cancels the query on the server.

Verified: go test in importer/ and mapsrv/, make build; new unit tests for parse,
validation, the fast/all policy and the failure summary; TestRealConfigsValidate
now validates every config's acceptance_tests blocks; all five configs' full
suites run live against the maps DB (-acceptance-only -acceptance all: 47 test
runs, 0 failures); the import hook verified with a real single-layer import
(-layer country_mask conf/ismap.yaml ran its test after the swap); failure, skip
and not-applicable paths exercised with throwaway configs. Writing the tests
surfaced one genuine finding: Iceland's map_cover_13_is contains 1 invalid
geometry (an estuary with a ring self-intersection) — filed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
art changed title from # #115: Formalized acceptance tests. to #115: Formalized acceptance tests. 2026-08-15 07:23:15 +00:00
The review comment on #115 asked for "global tests that aren't tied to a layer,
so that we can check that the MVT is sensible", with the index-coverage check as
the worked example. This adds the config-level home for them: a top-level
`acceptance_tests:` key on the config (merged through the include: machinery like
everything else in conf/common.yaml), run once per run — at the end of an import
(after the source functions are live, which the MVT test needs), at the end of a
-regeneralize run, and in -acceptance-only regardless of -layer selection.

Two global tests land in conf/common.yaml so every country config inherits them:
every map_* table's geom column has a valid gist index (the MVT functions query by
bounding box, so an unindexed table silently turns the tile path into a seq
scan), and the densest z5 map tile (Stockholm, measured ~1.8 MB) stays under 8 MB
— a byte budget whose point is catching a layer running away, not policing fine
tile sizes.

The skip rule now also covers undefined_function (42883) alongside
undefined_table (42P01): a global test calling zxy_map on a database where
world.yaml has never run skips the same way a layer test on a not-yet-imported
table does. The rest of the test-adding work is filed as #121-#125, one issue per
country plus the global/MVT remainder, per the review comment's chop-it-up
instruction.

Verified: importer and mapsrv go test, make build; unit test for the
42P01/42883 classification; every config's full suite live against the maps DB
(57 test runs, 0 failures), including the two globals in both -acceptance-only
modes (with and without -layer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review's central finding: the promise that acceptance tests survive a
partially-populated database held only for tests that named a partition table
directly. Verified against the live DB, the four shapes in the tree behaved
three different ways for the same situation:

  map_cover_13_se (names a partition)   42P01   SKIP
  string_agg(...) = '...'               NULL    SKIP  (by luck)
  count(*) FILTER (...) > 0             false   FAIL
  pg_inherits catalogue query           0 rows  FAIL

So `-acceptance-only conf/semap.yaml` against a database holding only Iceland
would have reported failures for Sweden's station ladder and Poland's ferry
partition. With #121-#125 about to pour forty more tests into this pattern,
whichever shape gets copied decides the behaviour — which is not a thing to
leave to chance.

Two fixes, in the machinery rather than in the SQL:

`requires:` — a test declares the tables it needs, `<cc>` expanding to the
owning layer's country code, so one line in a country config means that
country's own partition. Absent table, test skipped, named in the log.
Validated at startup: a name that is not a table name, or a `<cc>` on a test
with no country to substitute, fails before any DB work. This replaces the
Tornio test's hand-written CASE WHEN NOT EXISTS guard with
`requires: [map_roads_14_se, map_roads_14_fi]`, which says the same thing where
a reader will look for it.

The catalogue queries cannot use it — the parent tables exist from the
importer's first run, and it is the children that vary — so they aggregate
instead: bool_and over an empty set is NULL, which is already "not applicable",
where a per-row SELECT returned no rows and was reported as an unrun check.
They now also name the offending partitions through a FILTER column, which the
per-row form gave for free and the aggregate would otherwise lose.

The rest of the review:

- Every run ends with a tally line (N passed / failed / skipped / not run). An
  all-skipped run and an all-passed run both exited 0 and looked identical in
  the log; the review path pastes this into a PR body, so it has to say how much
  actually ran. In -acceptance-only, zero passes is itself a non-zero exit:
  that mode exists to produce a verdict and "everything skipped" is not one.
- `make acceptance` and `make acceptance-all`, because AGENTS.md says everything
  runs through the Makefile and this was the one thing that did not. `make test`
  now runs the importer's tests too, not only mapsrv's — it has been skipping
  them since before this branch, which this branch made matter.
- The gist-index test now checks for gist. It asserted only that some valid
  index had geom as a key column, so a btree on a geometry column would have
  passed it.
- The verdict logic is a pure function over plain values (acceptanceVerdict),
  with the pgx row-draining split out. It was the subtlest code here and the
  only part with no unit test, because *pgx.Rows is awkward to fake. Ten cases
  now cover it: true/false/NULL/all-NULL/no-rows/non-boolean, and that a failure
  reports the first *failing* row rather than the first row.
- R83 no longer passes vacuously on an empty places table (no rows means no
  duplicate pairs, which read as a verified invariant).
- formatAcceptanceDetail no longer sizes a slice with len(values)-1 (a
  zero-column result would have panicked; unreachable through Postgres, but
  free to fix), and a multi-row result says how many rows the detail is one of.
- gofmt.

Verified live: all five configs, 57 test runs, 0 failures, and the same numbers
as before (Sweden's R35 carve scan 18.2 s, Stockholm z5 tile 1 841 744 bytes).
`make acceptance` correctly defers the one 100s test and runs in ~7 s. Throwaway
configs exercised each path: `requires:` on a missing partition skips, a
catalogue query over a nonexistent layer skips, a nothing-verified review run
exits 1 with the reason, a 1s test against pg_sleep(30) fails at 1.001 s and
leaves no backend behind, and a mistyped column still fails rather than
skipping. A real `-layer country_mask conf/ismap.yaml` import ran its test after
the swap and printed its tally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review's other question was whether it is obvious that future work should be
tested through this mechanism rather than hand-rolled queries. It was not. The
rule existed in exactly one place — docs/importer-cli.md, a flag reference
nobody reads *before* verifying — while every document that actually directs the
work still described the hand-rolled path:

- AGENTS.md's first convention: "row counts per zoom per country, NULLs in the
  columns that matter, partitions present at every zoom, and a decoded tile".
  That is the hand-rolled path, and it is the first thing anyone reads.
- .claude/skills/work-issue/SKILL.md step 5 repeats it, and that is the file a
  subagent literally executes. A night-shift run would still hand-roll.
- AGENTS.md's Tornio paragraph said to re-check the border by hand after any
  shared-enum change, pointing at docs/country-finland.md's query — while R126
  had just become a test that runs on every roads import.
- docs/country-requirements.md is the check registry: 140 requirements, 72
  *Check:* lines, most of them raw SQL. R119's was literally the query the new
  test replaces. Nothing pointed from a requirement to the test that proves it,
  so the traceability ran one way only.
- No make target, in a project whose AGENTS.md says everything runs through the
  Makefile.

So:

AGENTS.md gains a convention next to the verify-before-committing one: a
verification worth doing twice is an acceptance test, not a query you retype.
Explore in psql freely — that is what it is for — but the check that decided the
work is correct gets written down. A PR that verifies a layer by pasting SQL
into its body, when that SQL could have been a test, is not finished. And if a
check genuinely cannot be expressed as one, say so and file an issue, because
that is a gap in the mechanism worth knowing about rather than a licence to go
back to psql.

work-issue's step 5 gets the operational form: after verifying, turn the checks
that mattered into tests in the same commit, run `make acceptance`, and paste
the tally into the note instead of a wall of ad-hoc SQL. A test that fails
before the fix and passes after is the strongest thing to put in front of a
reviewer. work-issues' handoff verification now expects to see that tally.

country-requirements.md gets a preamble explaining that the *Check:* lines are
migrating into the importer, and that a line still written as bare SQL is a
candidate rather than a decision to keep it manual — #121-#125 are that work.
Eleven requirements now name their test: R6, R14, R22, R35, R41, R46, R75, R83,
R85, R119, R123-R126. Where only half a requirement is covered the line says
which half and what the other half needs (R46's distribution is a judgement
call; R83's tile-key check needs decoding, which the SQL-only contract cannot
do; R125's area-collapse half is #117/#122; R126's full endpoint pairing is a
1000s-bucket candidate). R119's entry additionally says to copy its catalogue
pair for any layer without zoom_filters, since that is the whole obligation for
such a layer and two minutes of config.

The rest: importer-cli.md's section rewritten around the mechanism rather than
the flags — where a test lives, the four fields, the two ways to say "not
applicable" and which to prefer, when tests run, and how to read the tally.
README's make list and first-run block mention `make acceptance`, with a
paragraph on `make test` covering the code and `make acceptance` covering the
data. architecture.md's file map gains acceptance.go. AGENTS.md's docs index
points at the acceptance section for importer-cli.md, and its Tornio paragraph
now says the check is a test. country-finland.md's border-test section says the
issue #17 criterion is now literally an acceptance test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing the review commits (708e601, 8293e3c) against the live DB. Their
central fix is right and verified — `requires:` with `<cc>`, the aggregate
catalogue shape, the tally, the pure verdict function, the gist check — but one
case in their own "a typo fails, a missing object skips" rule was inverted, and
three small things read wrong:

1. **A function typo in a test was skipped, not failed.** isMissingObject treated
   every 42883 ("function does not exist") as the partial-database case, so a
   test calling `bool_and(integer)` — or misspelling any function — was silently
   skipped on every import, the exact opposite of the commit's own 42703
   (mistyped column) stance. Demonstrated live: a `bool_and(1)` probe skipped.
   The skip is now reserved for messages naming a `zxy_*` function, which is the
   only 42883 a partial database actually produces (zxy_map before world.yaml
   has run); a function typo fails, like a column typo. Unit test added.

2. **The tally said "(needs -acceptance all)" even when nothing was deferred.**
   An `-acceptance all` run printed "0 not run (needs -acceptance all)", which
   reads as if tests were left behind. The tail is now conditional on
   `notRun > 0`.

3. **The gist-index test's detail showed `tables=<nil>` on a clean run** — the
   `string_agg` over an empty set is NULL. COALESCE'd to ''.

4. **The "nothing was verified" exit message did not name the fast-mode cause**:
   a fast review of a slow-only config pointed at -layer selection and config,
   not at the mode. The message now says to run with -acceptance all when the
   tests are in the 100s/1000s buckets.

Verified: importer go test (incl. the new 42883 cases), gofmt, make test, make
build; all five configs in both `make acceptance` and `make acceptance-all` —
57 test runs, 0 failures, and the tally tail now only appears when it means
something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Review of the two review commits (708e601 machinery, 8293e3c docs) — verified against the live DB, not just read.

What holds up, verified:

  • The central finding is real: on a partial DB the four query shapes behaved three different ways (42P01 → skip, NULL → skip by luck, count(*) FILTER > 0 → false/fail, catalogue → 0 rows/fail). The commit's table is accurate. requires: with <cc> fixes it structurally — declared, validated at startup (regex + <cc>-only-where-there-is-a-country), parameterised to_regclass (no interpolation). Reproduced the skip live.
  • The aggregate catalogue shape (bool_and over empty = NULL → skip) works, and the string_agg ... FILTER columns do name the offenders on failure.
  • R83's empty-table CASE: an aggregate over an empty GROUP BY subquery still returns one row, so the CASE is reachable — empty table → NULL (skip), healthy → true. Verified both.
  • Tally + -acceptance-only zero-passes-exits-1: verified (a no-tests config exits 1 with the reason).
  • make acceptance / make acceptance-all / make test all work; the tally numbers are exactly as claimed (57 runs, 0 failures, R35 18.5 s, z5 tile 1 841 744 B).
  • The verdict refactor into a pure function with 10 unit cases is a real improvement — it was the only untested subtle code.
  • 42703 (mistyped column) fails, not skips — verified live.
  • The gist check now actually checks the access method. The docs commit is accurate throughout (AGENTS.md convention, work-issue step 5, the Check: migration, importer-cli.md rewrite); I spot-checked every claim.

One defect found and fixed (45f3c9a): the skip rule inverted the commit's own "typo fails, missing object skips" rule for functions. Every 42883 skipped, so a test calling bool_and(integer) (or misspelling any function) was silently skipped on every import — demonstrated live before the fix. 42883 now skips only when the message names a zxy_* function (the only 42883 a partial DB produces: zxy_map before world.yaml ran); a function typo fails like a column typo. Unit test added.

Three nits also fixed in 45f3c9a: the tally printed "(needs -acceptance all)" even when notRun = 0 (an -acceptance all run read as if it left tests behind); the gist test's detail showed tables=<nil> on a clean run; the "nothing was verified" message didn't name the fast-mode-on-slow-tests cause.

Two nits left, not worth commits: the commit message says "Eleven requirements now name their test" — it is fourteen (R6, R14, R22, R35, R41, R46, R75, R83, R85, R119, R123–R126); and in make acceptance the loop stops at the first failing config, so later configs' tallies are not printed — worth knowing when reading a failing run, not worth changing.

Verdict: the review is sound, the machinery is better for it, and the branch is green — 57 test runs, 0 failures, make test and make build clean. Ready for the maintainer.

Review of the two review commits (708e601 machinery, 8293e3c docs) — verified against the live DB, not just read. **What holds up, verified:** - The central finding is real: on a partial DB the four query shapes behaved three different ways (42P01 → skip, NULL → skip by luck, `count(*) FILTER > 0` → false/fail, catalogue → 0 rows/fail). The commit's table is accurate. `requires:` with `<cc>` fixes it structurally — declared, validated at startup (regex + `<cc>`-only-where-there-is-a-country), parameterised `to_regclass` (no interpolation). Reproduced the skip live. - The aggregate catalogue shape (`bool_and` over empty = NULL → skip) works, and the `string_agg ... FILTER` columns do name the offenders on failure. - R83's empty-table CASE: an aggregate over an empty GROUP BY subquery still returns one row, so the CASE is reachable — empty table → NULL (skip), healthy → true. Verified both. - Tally + `-acceptance-only` zero-passes-exits-1: verified (a no-tests config exits 1 with the reason). - `make acceptance` / `make acceptance-all` / `make test` all work; the tally numbers are exactly as claimed (57 runs, 0 failures, R35 18.5 s, z5 tile 1 841 744 B). - The verdict refactor into a pure function with 10 unit cases is a real improvement — it was the only untested subtle code. - 42703 (mistyped column) fails, not skips — verified live. - The gist check now actually checks the access method. The docs commit is accurate throughout (AGENTS.md convention, work-issue step 5, the *Check:* migration, importer-cli.md rewrite); I spot-checked every claim. **One defect found and fixed (45f3c9a):** the skip rule inverted the commit's own "typo fails, missing object skips" rule for *functions*. Every 42883 skipped, so a test calling `bool_and(integer)` (or misspelling any function) was silently skipped on every import — demonstrated live before the fix. 42883 now skips only when the message names a `zxy_*` function (the only 42883 a partial DB produces: `zxy_map` before world.yaml ran); a function typo fails like a column typo. Unit test added. **Three nits also fixed in 45f3c9a:** the tally printed "(needs -acceptance all)" even when notRun = 0 (an `-acceptance all` run read as if it left tests behind); the gist test's detail showed `tables=<nil>` on a clean run; the "nothing was verified" message didn't name the fast-mode-on-slow-tests cause. **Two nits left, not worth commits:** the commit message says "Eleven requirements now name their test" — it is fourteen (R6, R14, R22, R35, R41, R46, R75, R83, R85, R119, R123–R126); and in `make acceptance` the loop stops at the first failing config, so later configs' tallies are not printed — worth knowing when reading a failing run, not worth changing. Verdict: the review is sound, the machinery is better for it, and the branch is green — 57 test runs, 0 failures, `make test` and `make build` clean. Ready for the maintainer.
art merged commit 45f3c9ad4e into main 2026-08-15 09:32:12 +00:00
art deleted branch issue-115-acceptance-tests 2026-08-15 09:32:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!119
No description provided.