#!/usr/bin/env bash
# scripts/test — run the suite (or a subset) in the precis-dev container
# against THIS worktree, with the RAM test DB wired, and terse "token-killer"
# output (warnings suppressed, short tracebacks). The canonical way to check
# tests while iterating.
#
# Why a wrapper: the host venv is torch-free, so a bare `uv run pytest` on the
# host fails with spurious ModuleNotFoundError; and `scripts/dev` bind-mounts
# MAIN, not your worktree, so it can't see your edits. This runs in the
# container over a bind-mount of the CURRENT worktree, exactly like the
# scripts/ship gate — so it tests what you're actually editing.
#
# The ship gate (scripts/ship, run by /land and /go) is still the
# authoritative pre-merge run; this is the fast iteration loop.
#
# Usage:
#   scripts/test                              # full suite (-n6)
#   scripts/test tests/test_foo.py            # one file
#   scripts/test -k "registry or env"         # a -k expression
#   scripts/test tests/test_foo.py::test_bar -x
#   scripts/test --fast                       # no-DB fast set (-m 'not db')
#   scripts/test --impacted                   # ONLY tests your changes affect
#   scripts/test --typecheck                  # `uv run mypy src tests` in the container
#   scripts/test --bg <args…>                 # detach the run; prints an --await line
#   scripts/test --await <run-id>             # bounded wait: exits with the run's
#                                             # code, or 124 = still running (re-run)
#
# Everything after the script name is passed straight to pytest. Default
# parallelism is -n6 (the measured sweet spot); pass your own -n0/-nN to
# override for a single fast test.
#
# --impacted (-i): impact selection via pytest-testmon. Runs only the tests
# affected by your working-tree changes — the inner-loop accelerator when the
# full 140s suite is mostly per-worker DB-clone setup for tests you didn't
# touch. The FIRST run builds the test↔code map (a full run); after that each
# run selects just the affected tests (sub-second when nothing changed). Forces
# -n0 (testmon is per-process) and keeps a per-worktree map at ./.testmondata
# (gitignored). Still not authoritative — scripts/ship runs the full gate.
#
# NOTE: the dev image bakes all extras, so you never need `--with`/`--extra`
# here — that's only for the torch-free host (and the testmon plugin, which
# --impacted pulls in on the fly so no image rebuild is needed).
#
# --typecheck : the gate's mypy check (`uv run mypy src tests`), through the
# same container path as pytest — same compose project, same worktree bind
# mount — so it sees the SAME tree the rest of this script tests, and the
# baked-image extras (fastapi/rdkit/torch) that a bare host `uv run mypy`
# lacks. Skips the DB startup and the gate-slot wait: mypy touches no
# service and is light next to a full pytest run, so it doesn't need to
# queue behind the shared-VM gate-slot cap (gr202193/gr339251). Optional
# trailing args override the default `src tests` target.
set -euo pipefail
cd "$(dirname "$0")/.."
WORKTREE="$PWD"

# --help / -h : print usage and exit BEFORE any side effect (gr343798). This
# must stay the first thing checked — no gate slot, no tmpfs test-db, no
# container, no lock — so `scripts/test --help` is instant and never touches
# the fleet-wide gate.
usage() {
    cat <<'USAGE'
scripts/test — run the suite (or a subset) in the precis-dev container
against THIS worktree, with the RAM test DB wired.

Usage:
  scripts/test                              # full suite (-n6)
  scripts/test tests/test_foo.py            # one file
  scripts/test -k "registry or env"         # a -k expression
  scripts/test tests/test_foo.py::test_bar -x
  scripts/test --fast | -f                  # no-DB fast set (-m 'not db and not slow')
  scripts/test --impacted | -i              # ONLY tests your changes affect (testmon)
  scripts/test --bg <args…>                 # detach the run; prints an --await line
  scripts/test --await <run-id>             # bounded wait: exits with the run's
                                             # code, or 124 = still running (re-run)
  scripts/test --help | -h                  # this message; no gate slot, no container

Everything after the script name (other than the flags above) is passed
straight to pytest. Default parallelism is -n6; pass your own -n0/-nN to
override for a single fast test.

Two-call background protocol for capped foreground shells: `--bg <args>`
detaches the run and prints its run-id; poll with `--await <run-id>`
repeatedly (each call blocks up to PRECIS_TEST_AWAIT_BUDGET seconds, default
480, and exits 124 while still running — re-run the same command until it
returns the run's real exit code).

See the header comment in this script for the full rationale (why the
wrapper exists, --impacted/testmon details, the UV_WITH escape hatch).
USAGE
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
    usage
    exit 0
fi

# --bg / --await : two-call protocol for agent harnesses whose foreground
# shell calls are hard-capped (~10 min). A gate-congested run can't fit one
# call, and the cap KILLS the run mid-flight (a re-run starts over at the
# back of the gate queue) — which is what pushed subagents onto background-
# task notifications and the park-mid-verification stall. --bg detaches the
# run into its own session (survives the caller's shell exiting; the gate
# slot self-heals via the pid-dead steal if the run is abandoned); --await
# blocks in chunks that fit under the cap, so a long run is a sequence of
# short foreground calls with the agent active throughout. One run at a
# time — a second --bg queues behind the same gate slots and doubles load.
RUNS_DIR="${TMPDIR:-/tmp}/precis-test-runs"
if [[ "${1:-}" == "--bg" ]]; then
    shift
    mkdir -p "$RUNS_DIR"
    RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$"
    python3 - "$0" "${RUNS_DIR}/${RUN_ID}" "$@" <<'PY'
import os
import subprocess
import sys

script, base = sys.argv[1:3]
args = sys.argv[3:]
if os.fork() == 0:
    os.setsid()
    # Detach from the caller's stdio: the forked child inherits the
    # parent's fds, so a piped invocation (`--bg ... | tail`) would
    # otherwise never see EOF until the whole run finished.
    devnull = os.open(os.devnull, os.O_RDWR)
    for fd in (0, 1, 2):
        os.dup2(devnull, fd)
    with open(f"{base}.log", "wb") as out:
        try:
            rc = subprocess.call(
                [script, *args],
                stdin=subprocess.DEVNULL,
                stdout=out,
                stderr=subprocess.STDOUT,
            )
        except Exception:
            rc = 127
    with open(f"{base}.exit", "w", encoding="utf-8") as f:
        f.write(f"{rc}\n")
    os._exit(0)
PY
    echo "test run ${RUN_ID} detached (log: ${RUNS_DIR}/${RUN_ID}.log)"
    echo "poll it with:  scripts/test --await ${RUN_ID}"
    exit 0
fi
if [[ "${1:-}" == "--await" ]]; then
    RUN_ID="${2:?usage: scripts/test --await <run-id>}"
    LOG="${RUNS_DIR}/${RUN_ID}.log"
    EXITF="${RUNS_DIR}/${RUN_ID}.exit"
    [[ -f "$LOG" ]] || { echo "ERR: no such test run ${RUN_ID} (no log at ${LOG})" >&2; exit 1; }
    BUDGET="${PRECIS_TEST_AWAIT_BUDGET:-480}"
    START=$SECONDS
    while (( SECONDS - START < BUDGET )); do
        if [[ -f "$EXITF" ]]; then
            rc="$(tr -dc '0-9' <"$EXITF")"
            echo "── last 40 log lines (full log: ${LOG}) ──"
            tail -n 40 "$LOG"
            echo "test run ${RUN_ID} finished — exit ${rc:-1}"
            exit "${rc:-1}"
        fi
        sleep 5
    done
    echo "test run ${RUN_ID} STILL RUNNING after ${BUDGET}s — poll again with the same command:"
    echo "  scripts/test --await ${RUN_ID}"
    tail -n 2 "$LOG" 2>/dev/null || true
    exit 124
fi

# --impacted / -i : testmon impact-selection mode (see header). Strip the
# sentinel before the rest is forwarded to pytest.
# --fast / -f : the fast set — `-m 'not db and not slow'`: skips every test that
# needs Postgres (auto-marked `db` in tests/conftest.py) AND the heavy `slow`
# compute cluster. No Postgres is started, so it runs in seconds and is
# independent of the test DB. The right gate for pure-logic / docs / config
# changes; NOT authoritative before a deploy (/go still runs the full suite).
IMPACTED=0
FAST=0
if [[ "${1:-}" == "--impacted" || "${1:-}" == "-i" ]]; then IMPACTED=1; shift; fi
if [[ "${1:-}" == "--fast" || "${1:-}" == "-f" ]]; then FAST=1; shift; fi

INFRA_COMPOSE="${PRECIS_COMPOSE:-${PWD}/docker/dev/compose.yaml}"
[[ -f "$INFRA_COMPOSE" ]] || {
    echo "ERR: compose file not found at ${INFRA_COMPOSE} (set PRECIS_COMPOSE)" >&2
    exit 1
}
# Per-worktree compose project so this worktree's precis-test-db is isolated
# from every sibling's, instead of all colliding on the default project `dev`
# (gr176375). See scripts/lib/compose-project.sh.
source "${WORKTREE}/scripts/lib/compose-project.sh"
COMPOSE_PROJECT="$(compose_project_for "$WORKTREE")"
# Gate-slot admission (gr202193): the test container runs against one shared
# Docker VM ceiling with every sibling worktree's — cap concurrency so runs
# queue briefly instead of OOM-killing each other at random (exit 137).
source "${WORKTREE}/scripts/lib/gate-slot.sh"
trap gate_slot_release EXIT
compose() { env UID="$(id -u)" GID="$(id -g)" docker compose -f "$INFRA_COMPOSE" -p "$COMPOSE_PROJECT" --profile dev "$@"; }

# --typecheck : reuses the compose project/mount above but skips the test-db
# startup and the gate-slot wait entirely (see header) — runs mypy and exits
# with its status, never falling through to the pytest path below.
if [[ "${1:-}" == "--typecheck" ]]; then
    shift
    MYPY_ARGS=("$@")
    [[ ${#MYPY_ARGS[@]} -eq 0 ]] && MYPY_ARGS=(src tests)
    compose run --rm --no-deps -v "${WORKTREE}":/app precis-dev \
        bash -lc 'uv run --no-sync mypy "$@"' _ "${MYPY_ARGS[@]}"
    exit $?
fi

# Co-located RAM test DB (the same service scripts/ship's gate uses). If it
# can't start, fall back to whatever PRECIS_TEST_PG_URL the container carries.
# --fast runs `-m 'not db'`, so no DB test is selected — skip the DB startup
# entirely (a real time saver and it makes the fast path Docker-DB-independent).
# One BLAS/OpenMP thread per xdist worker. Uncapped, every worker that
# touches torch/numpy sizes its pool to all container cores (15 on the M-series
# hosts), so `-n 6` runs ~190 runnable threads on 15 cores and a torch test
# that takes seconds alone crawls for tens of minutes under coverage tracing —
# the "wedged gate" that never names a wedged test (gr345784). Set here, in
# the container env, so every runtime (libgomp, OpenBLAS, MKL, numexpr) reads
# it at load, before any test module imports torch. The same keys sit on the
# warm precis-gate service in docker/dev/compose.yaml.
THREAD_CAP_ENV=(
    -e OMP_NUM_THREADS=1 -e MKL_NUM_THREADS=1 -e OPENBLAS_NUM_THREADS=1
    -e NUMEXPR_NUM_THREADS=1 -e VECLIB_MAXIMUM_THREADS=1
)

TEST_DB_ENV=()
if [[ "$FAST" == "1" ]]; then
    :  # no DB tests selected — don't pay for Postgres
elif compose up -d --wait precis-test-db >/dev/null 2>&1; then
    TEST_DB_ENV=(-e "PRECIS_TEST_PG_URL=postgresql://postgres@precis-test-db:5432/precis_test")
else
    echo "WARNING: precis-test-db didn't start — falling back to the container's default test DB" >&2
fi

# Default -n6 unless the caller set their own -n (single fast test → pass -n0).
NPROC=(-n6)
for a in "$@"; do [[ "$a" == -n* ]] && NPROC=(); done

# Impacted mode: pull in the testmon plugin on the fly (no image rebuild),
# enable selection, force -n0 (testmon is per-process — xdist fragments the
# map), and persist the map per-worktree at /app/.testmondata (the bind-mount,
# so it survives on the host between runs). UV_WITH is expanded unquoted inside
# the container so an empty value adds nothing in normal mode.
#
# A caller-set UV_WITH passes through — the escape hatch for a worktree that
# ADDS a core dependency the baked image doesn't have yet:
#   UV_WITH="--with newdep" scripts/test tests/test_newdep.py
# (the ship gate itself runs `uv run` WITH sync, so it resolves new deps from
# uv.lock without an image rebuild; this passthrough gives the fast loop the
# same reach).
UV_WITH="${UV_WITH:-}"
TESTMON_ARGS=()
TESTMON_ENV=()
if [[ "$IMPACTED" == "1" ]]; then
    UV_WITH="${UV_WITH:+${UV_WITH} }--with pytest-testmon"
    TESTMON_ARGS=(--testmon)
    TESTMON_ENV=(-e "TESTMON_DATAFILE=/app/.testmondata")
    NPROC=(-n0)
fi

# Fast mode: deselect the whole DB suite (auto-applied `db` marker) AND the
# heavy `slow` cluster. Both matter: the slowest two tests in the suite are
# no-DB compute tests in the `slow` pathway file, so `not db` alone would let
# them leak into the "fast" set. See docs/conventions/testing.md.
FAST_ARGS=()
if [[ "$FAST" == "1" ]]; then FAST_ARGS=(-m "not db and not slow"); fi

# Take a fleet-wide gate slot just before the heavyweight container run (the
# tiny tmpfs test-db above doesn't need one).
gate_slot_acquire

# Artifact-writing tests (the PCB fab render, chiefly) need to be TOLD where to
# put their output, and nothing but UV_WITH used to cross the container
# boundary — so producing a board to look at meant hand-editing a default path
# into the test and reverting it afterwards. The path is interpreted INSIDE the
# container, where this worktree is mounted at /app, so a repo-relative path
# lands on the host.
ARTIFACT_ENV=()
for _render_var in PRECIS_PCB_RENDER_OUT PRECIS_PCB_RENDER_SEED PRECIS_PCB_RENDER_FIXTURE PRECIS_PCB_RENDER_ITERS; do
    if [[ -n "${!_render_var:-}" ]]; then
        ARTIFACT_ENV+=(-e "${_render_var}=${!_render_var}")
    fi
done

# --no-sync: the image already bakes the venv and installs /app editable, so
# the bind-mounted source is live without a per-run reinstall (that reinstall
# was pure noise + latency — the reason sessions hand-rolled `--no-sync`).
# -p no:warnings drops the warnings summary (noise while iterating); --tb=short
# keeps tracebacks compact; -q drops per-test verbosity. Pass pytest args as
# inner positional params ("$@") so no cross-shell re-quoting is needed.
#
# Preflight (gr286507): a stale baked venv (new core dep promoted, image not
# yet rebuilt) otherwise surfaces as ModuleNotFoundError red tests in whatever
# file pytest collects first — unrelated to the change under test, and
# expensive to root-cause from cold. check-core-deps.py imports every
# `[project] dependencies` entry under the SAME `uv run --no-sync ${UV_WITH}`
# prefix as pytest (so a UV_WITH bridge is honoured here too) and fails loud,
# before pytest starts, naming the missing module(s) and the fix. One extra
# `uv run` in the same container — no second container spin-up.
compose run --rm --no-deps -e UV_LINK_MODE=copy -e UV_WITH="$UV_WITH" \
    "${THREAD_CAP_ENV[@]}" \
    "${TEST_DB_ENV[@]+"${TEST_DB_ENV[@]}"}" "${TESTMON_ENV[@]+"${TESTMON_ENV[@]}"}" \
    "${ARTIFACT_ENV[@]+"${ARTIFACT_ENV[@]}"}" \
    -v "${WORKTREE}":/app precis-dev \
    bash -lc 'uv run --no-sync ${UV_WITH} python3 scripts/lib/check-core-deps.py && uv run --no-sync ${UV_WITH} pytest -q --no-header --tb=short -p no:warnings "$@"' \
    _ "${NPROC[@]+"${NPROC[@]}"}" "${TESTMON_ARGS[@]+"${TESTMON_ARGS[@]}"}" \
    "${FAST_ARGS[@]+"${FAST_ARGS[@]}"}" "$@"
