Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions HANDOVER_QWAC.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,17 @@ pinned to one version. The plan holds, but:
`registry/entities/<slug>/fixtures/xlsform.json`, not
`registry/types/<slug>/examples/<variant>/xlsform.json`. And `lstsv2ddi` /
`lstsv2xlsform` now exist, which the brief predates.
- **Coverage handoff:** survey2ddi's qwacback equivalence test is being ported
into formtransform (formtransform#14). qwacback's own converter tests go
with the Go converter.
- **Coverage handoff:** survey2ddi's qwacback equivalence test now lives in
formtransform (`tests/live/qwacback/`, formtransform#14). Against qwacback
`main` (`c99de96`), 13 of 15 types match. The two that differ change
qwacback's DDI when it swaps converters:
- `range`: qwacback emits numeric/`contin`, formtransform text (unregistered
type, formtransform#33).
- `note`: qwacback emits a `<var>`, formtransform none. This is intended: a
note stores no response, so formtransform folds it into
`<preQTxt>`/`<notes>`.

qwacback's own converter tests go with the Go converter.

## Hard rules for whoever does the work

Expand Down
27 changes: 24 additions & 3 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ everything that needs a JVM, an external oracle, or a running engine.
| XLSForm fixture inputs are valid XLSForm | external oracle ([pyxform](https://github.com/XLSForm/pyxform)) | pytest — `tests/validation/test_xlsform_pyxform.py` |
| LimeSurvey accepts the blessed TSV snapshots | live LimeSurvey (docker) | pytest — `tests/live/limesurvey/test_registry_entities.py` |
| What LimeSurvey *stores* when a respondent answers | live LimeSurvey (docker) + Playwright | pytest — `tests/live/limesurvey/test_response_roundtrip.py` |
| qwacback's Go converter emits the same DDI shape as `buildDdiXml` | live qwacback (docker) | pytest — `tests/live/qwacback/test_qwacback_equivalence.py` |

Downstream `qwacback` (Go) is no longer conformance-tested here: it consumes
this library's DDI emitter through `@correlaid/formtransform`, installed from
GitHub, rather than maintaining its own converter.
qwacback still runs its own Go XLSForm → DDI converter. It plans to replace it
with this library ([HANDOVER_QWAC.md](../HANDOVER_QWAC.md)), and the
equivalence test is the parity check for that swap. After the swap it compares
the library with itself and can go.

## Layout

Expand Down Expand Up @@ -62,6 +64,10 @@ tests/
answers/<slug>.json # what the respondent enters
expected/<slug>.json # blessed exported response
output/ # generated TSVs (gitignored)
qwacback/
docker-compose.yml # qwacback alone (QWACBACK_IMAGE; the ghcr image is private)
test_qwacback_equivalence.py # same XLSForm → buildDdiXml vs. qwacback, DDI shape compared
build_ddi.mjs # buildDdiXml from dist/ over stdin/stdout
fixtures/surveys/<name>/ # one folder per whole survey, like a registry entity
xlsform.json | xlsform.xlsx # authored source
tsv.tsv ddi.xml # blessed forward snapshots
Expand Down Expand Up @@ -175,6 +181,21 @@ one — a note stores nothing, and the blessed snapshot records that).
Needs node + Playwright/Chromium on top of the docker stack; skips if Playwright
is not resolvable (locally or globally).

### qwacback equivalence (`tests/live/qwacback/test_qwacback_equivalence.py`)

Ported from survey2ddi (formtransform#14). For every answer type qwacback
supports, the same XLSForm goes through `buildDdiXml` and qwacback's
`POST /api/convert/xlsform-to-ddi`, and the `<var>`/`<varGrp>` shapes are
compared. qwacback returns a bare `<var>` or `<varGrp>` when there's only one,
so the test wraps it in a `<dataDscr>`. Two cases are strict xfails: `range`
(#33) and `note` (by design: formtransform emits no `<var>` for a note).

The fixture starts qwacback from its own compose file, or uses `QWACBACK_URL`.
`ghcr.io/correlaid/qwacback` is private: log in to ghcr.io, or build it
(`docker build -t qwacback:local ../qwacback`) and set
`QWACBACK_IMAGE=qwacback:local`. If the image can't be pulled, the tests skip
with that reason.

## Running

```bash
Expand Down
8 changes: 8 additions & 0 deletions tests/live/qwacback/build_ddi.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Reads {"survey": [...], "choices": {...}} on stdin and prints the DDI XML
// that formtransform's buildDdiXml produces, using the built dist/.
import { buildDdiXml } from '../../../dist/index.js';

let input = '';
for await (const chunk of process.stdin) input += chunk;
const { survey, choices } = JSON.parse(input);
process.stdout.write(buildDdiXml(survey, choices));
120 changes: 120 additions & 0 deletions tests/live/qwacback/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Fixtures for comparing formtransform's DDI with qwacback's.

`QWACBACK_URL` reuses a running qwacback. Otherwise the fixture starts the
container from docker-compose.yml and removes it afterwards. The tests are
skipped when the qwacback image can't be pulled (it is private; see
docker-compose.yml).
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import time
from pathlib import Path

import pytest
import requests

HERE = Path(__file__).parent
REPO_ROOT = HERE.parents[2]
COMPOSE_FILE = HERE / "docker-compose.yml"
BUILD_DDI = HERE / "build_ddi.mjs"
READY_TIMEOUT_S = 120.0


def _wait_for_api(base: str) -> None:
deadline = time.monotonic() + READY_TIMEOUT_S
while time.monotonic() < deadline:
try:
if requests.get(f"{base}/api", timeout=2).status_code == 200:
return
except requests.RequestException:
pass
time.sleep(1)
pytest.fail(f"qwacback /api not ready within {READY_TIMEOUT_S:.0f}s at {base}")


@pytest.fixture(scope="session")
def qwacback_url():
if url := os.environ.get("QWACBACK_URL"):
base = url.rstrip("/")
_wait_for_api(base)
yield base
return

if not shutil.which("docker"):
pytest.skip("docker not available (set QWACBACK_URL to use a running qwacback)")

compose = ["docker", "compose", "-p", f"ft-qwacback-{os.getpid()}", "-f", str(COMPOSE_FILE)]
pull = subprocess.run([*compose, "pull", "qwacback"], capture_output=True, text=True)
image_present = (
subprocess.run(
["docker", "image", "inspect", os.environ.get("QWACBACK_IMAGE", "ghcr.io/correlaid/qwacback:latest")],
capture_output=True,
).returncode
== 0
)
if pull.returncode != 0 and not image_present:
pytest.skip(f"qwacback image unavailable, see {COMPOSE_FILE.relative_to(REPO_ROOT)}: {pull.stderr.strip()}")

subprocess.run([*compose, "up", "-d", "--wait", "--wait-timeout", str(int(READY_TIMEOUT_S))], check=True)
base = f"http://127.0.0.1:{os.environ.get('QWACBACK_PORT', '8090')}"
try:
_wait_for_api(base)
yield base
finally:
subprocess.run([*compose, "down", "-v", "--remove-orphans"], check=False)


def _post_json(url: str, payload: dict, max_wait_s: float = 90.0) -> requests.Response:
"""POST, backing off on 429: qwacback allows guests 10 conversions a minute."""
waited = 0.0
while True:
r = requests.post(url, json=payload, timeout=30)
if r.status_code != 429:
return r
pause = min(float(r.headers.get("Retry-After") or 5.0), 10.0)
if waited + pause > max_wait_s:
return r
time.sleep(pause)
waited += pause


@pytest.fixture
def qwacback_ddi(qwacback_url):
"""DDI XML from qwacback's POST /api/convert/xlsform-to-ddi."""

def _convert(survey: list[dict], choices: dict[str, list[dict]]) -> str:
payload = {
"survey": survey,
"choices": [{"list_name": ln, **c} for ln, cs in choices.items() for c in cs],
"settings": {},
}
r = _post_json(f"{qwacback_url}/api/convert/xlsform-to-ddi", payload)
assert r.status_code == 200, f"qwacback -> HTTP {r.status_code}: {r.text[:500]}"
return r.text

return _convert


@pytest.fixture(scope="session")
def formtransform_ddi():
"""DDI XML from formtransform's buildDdiXml (the built dist/)."""
if not (REPO_ROOT / "dist" / "index.js").exists():
pytest.fail("dist/index.js missing: run `npm run build` first")

def _convert(survey: list[dict], choices: dict[str, list[dict]]) -> str:
out = subprocess.run(
["node", str(BUILD_DDI)],
input=json.dumps({"survey": survey, "choices": choices}),
capture_output=True,
text=True,
check=False,
)
assert out.returncode == 0, out.stderr
return out.stdout

return _convert
23 changes: 23 additions & 0 deletions tests/live/qwacback/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# qwacback alone, for the DDI equivalence test (test_qwacback_equivalence.py).
# Conversion runs in qwacback's Go code, so the schematron-worker isn't needed.
#
# ghcr.io/correlaid/qwacback is private. Log in to ghcr.io, or build it from a
# qwacback checkout and point QWACBACK_IMAGE at it:
# docker build -t qwacback:local ../qwacback
# QWACBACK_IMAGE=qwacback:local npm run test:live -- -k qwacback
services:
qwacback:
image: ${QWACBACK_IMAGE:-ghcr.io/correlaid/qwacback:latest}
ports:
- "${QWACBACK_PORT:-8090}:8080"
environment:
- QWACBACK_SKIP_SEED=1
- NATS_PORT=4222
- NATS_TOKEN=${NATS_TOKEN:-changeme}
command: ["./qwacback", "serve", "--http=0.0.0.0:8080"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/api"]
interval: 2s
timeout: 2s
retries: 30
start_period: 10s
Loading
Loading