# Makefile for use with GNU make

# Tests assert default (non-agent) output formats, so neutralize AI_AGENT (which
# the harness running these tests may set) for all recipes. Tests that exercise
# the AI_AGENT=TOON default set it explicitly per command (e.g. `AI_AGENT=1 ...`).
unexport AI_AGENT

THIS_MAKEFILE_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
THIS_DIR:=$(shell basename "${THIS_MAKEFILE_DIR}")
THIS_MAKEFILE:=$(lastword $(MAKEFILE_LIST))

CONFIGFILE ?= ../../config.mk
ifneq ($(QUIET),1)
  $(info # Using config file ${CONFIGFILE})
endif
include ${CONFIGFILE}

CONFIGFILEPATH=$(shell ls ${CONFIGFILE} >/dev/null 2>/dev/null && realpath ${CONFIGFILE})
ifeq ($(CONFIGFILEPATH),)
  $(error Config file ${CONFIGFILE} not found)
endif

CC ?= cc

WIN=
ifeq ($(WIN),)
  # MINGW = 1 in config.mk (configure's host check; not the environment, and a CC given
  # on the command line does not undo it), or a w64 compiler
  WIN=$(if $(and $(filter file,$(origin MINGW)),$(filter 1,$(MINGW))),1,$(if $(findstring w64,$(CC)),1,0))
endif

ifeq ($(DEBUG),1)
  DBG_SUBDIR+=dbg
else
  DBG_SUBDIR+=rel
endif

ifeq ($(WIN),0)
  BUILD_SUBDIR=$(shell uname)/${DBG_SUBDIR}
  EXE=
else
  BUILD_SUBDIR=win/${DBG_SUBDIR}
  EXE=.exe
endif
# suffix the compiler gives a host-compiled test binary (test_*.c): mingw adds .exe
# to the -o name, so the rule's target must carry it. Under emcc EXE names the
# zsv_* wrappers, while these binaries keep their bare name
HOST_EXE=$(if $(filter 1,${WIN}),.exe,)

# WSL passes a Windows executable only the environment variables WSLENV names, so
# the variables these tests set per command (VAR=x ${PREFIX} ...) are added to it
ifeq ($(WIN),1)
  export WSLENV:=$(if ${WSLENV},${WSLENV}:)SOURCE_DATE_EPOCH:AI_AGENT
endif

# Windows executables write stdout in text mode, ending each line in \r\n. A check
# that compares a line of a program's output exactly passes it through STRIP_CR,
# which on Windows drops the trailing \r (as CMP, diff --strip-trailing-cr there, does), so
# there it cannot tell \r\n output from \n output. Elsewhere it changes nothing
ifeq ($(WIN),1)
  STRIP_CR=awk '{sub(/\r$$/,"")}1'
else
  STRIP_CR=cat
endif

export THIS_LIB_BASE=$(shell cd ../.. && pwd)
CCBN=$(shell basename ${CC})
BUILD_DIR=${THIS_LIB_BASE}/build/${BUILD_SUBDIR}/${CCBN}
export TMP_DIR=${THIS_LIB_BASE}/tmp

# sheet's status messages must stay up for as long as a test needs to compare a fixture
# against the screen: 0 keeps each message until another replaces it. test-sheet-status-timeout
# sets a value of its own. tmux passes the environment of the recipe that starts its server on
# to the session, and every sheet test starts its server fresh
export ZSVSHEET_STATUS_TIMEOUT_MS=0
TEST_DATA_DIR=${THIS_LIB_BASE}/data

ifeq ($(WIN),1)
  TEST_DATA_DIR=./../../data
  TMP_DIR=./../../tmp
endif

SOURCES=count echo count-pull select select-pull sql 2json serialize flatten pretty desc stack 2db 2tsv jq compare
ifneq ($(ZSV_EXTRAS),)
  SOURCES+=overwrite
endif
# 2toon exists only when TOON features are enabled (configure --disable-toon omits it)
ifneq ($(ZSV_NO_TOON),1)
  SOURCES+=2toon
  # test-compare's --toon equivalence check pipes through 2toon (empty under --disable-toon)
  COMPARE_TOON_DEP=${BUILD_DIR}/bin/zsv_2toon${EXE}
endif
TARGETS=$(addprefix ${BUILD_DIR}/bin/zsv_,$(addsuffix ${EXE},${SOURCES}))

TESTS=$(addprefix test-,${SOURCES}) test-blank-leading-rows test-rm test-mv test-mv-utf8 test-2json-help test-paste test-flatten-2 test-flatten-dupcol
ifneq ($(ZSV_NO_TOON),1)
  TESTS+=test-2toon-help
endif

ifeq ($(ZSVSHEET_BUILD),1)
  SOURCES+=sheet
  CFLAGS+=-DZSVSHEET_BUILD
  CFLAGS+=${CFLAGS_NCURSES}
  LDFLAGS+=${LDFLAGS_NCURSES}
  TESTS+=test-sheet test-sheet-subcommand
endif

ifneq ($(ZSV_NO_PARALLEL),1)
  TESTS+=test-parallel
endif
TESTS+=test-check
ifneq ($(ZSV_EXTRAS),)
  TESTS+=test-limit-rows-pull
endif
TESTS+=test-rename-select
TESTS+=test-quoted-nonstandard
TESTS+=test-tab-auto-recognize
# The redline tests pin output determinism via SOURCE_DATE_EPOCH and exercise the unified
# `cli` / `--help-topic` routing. emscripten's getenv does not see host env vars (only a
# minimal built-in ENV), and that multi-binary orchestration is not set up in the emcc/wasm
# test sandbox, so register these on native builds only. The --json-redline feature is plain
# C and is covered on every native platform (cf. the CROSS_COMPILING guard below).
ifneq ($(CCBN),emcc)
TESTS+=test-compare-redline
TESTS+=test-compare-redline-v2
# test-2db-csv pipes zsv_2json | zsv_2db and orchestrates multiple standalone
# binaries; like the redline tests, this multi-binary orchestration is not set
# up in the emcc/wasm sandbox (binaries are *.em.js run via node, not directly
# executable). The CSV->DB path is plain C and is covered on every native build.
TESTS+=test-2db-csv
# Uniform unrecognized-option rejection across commands; execs the standalone
# binaries directly, so native-only (like the redline/2db-csv orchestration above).
TESTS+=test-unrecognized-option
# --stdin-filename applies saved props to stdin input, so its tests pipe data via
# stdin (< data.csv); emscripten's node sandbox has no usable stdin, so wire these
# into the select/sql aggregates on native builds only. Left empty under emcc.
STDIN_FILENAME_SELECT_TESTS=test-stdin-filename-%
STDIN_FILENAME_SQL_TESTS=test-stdin-filename-sql
# --sample-size pipes input via stdin and uses a FIFO; same native-only wiring
TESTS+=test-sample-select
endif

# Security regression test - only run when not cross-compiling
ifneq ($(CROSS_COMPILING),1)
  TESTS+=test-vuln-cve-underflow
  TESTS+=test-rng # host-compiled reference-vector check of zsv/utils/rng
  TESTS+=test-strcasestr # host-compiled unit test of the caseless UTF-8 search in zsv/utils/string
  TESTS+=test-sheet-edits # host-compiled unit test of the sheet cell-edit container
  # the overlay reaches curses through sheet/terminal.h, so it is built only where sheet is
  ifeq ($(ZSVSHEET_BUILD),1)
    TESTS+=test-sheet-cell-overlay # host-compiled unit test of the sheet whole-cell overlay
    TESTS+=test-sheet-status-message # host-compiled unit test of the sheet status message lifetime
    ifeq ($(WIN),0)
      TESTS+=test-sheet-clipboard # host-compiled unit test of the sheet clipboard module
    endif
  endif
  TESTS+=test-args # host-compiled unit test of the common option matching in zsv/utils/arg
  ifeq ($(WIN),0)
    TESTS+=test-mkdirs # host-compiled check that zsv_mkdirs folders follow the umask (POSIX only)
  endif
  # under emcc the test runs in node's in-memory filesystem, where ${TMP_DIR} does not exist
  ifneq ($(CCBN),emcc)
    TESTS+=test-replace-file # host-compiled unit test of the zsv/utils/os file helpers, incl. Windows long paths
  endif
  # ASan-instrumented regression for the zsv_finish->cell_dl OOB. Skipped
  # if SAN_CC (clang+sanitizers) is unavailable; build target itself runs
  # the sanitizer.
  ifneq ($(shell command -v clang 2>/dev/null),)
    TESTS+=test-vuln-finish-cell-dl-oob
    TESTS+=test-vuln-fast-nonstandard-oob
  endif
  # SQL-command security regressions. The 2db/crossjoin cases exec the
  # standalone binaries directly (like the redline/2db-csv orchestration); the
  # authorizer unit test is a host-compiled pure-C policy check. All are
  # native-only: under emcc the test binaries are wasm artifacts that must run
  # via node, which this simple direct-exec wiring does not set up.
  ifneq ($(CCBN),emcc)
    TESTS+=test-vuln-2db-injection
    TESTS+=test-vuln-sql-crossjoin
    TESTS+=test-vuln-sql-readonly
  endif
endif

COLOR_NONE=\033[0m
COLOR_GREEN=\033[1;32m
COLOR_RED=\033[1;31m
COLOR_BLUE=\033[1;34m
COLOR_PINK=\033[1;35m
COLOR_YELLOW=\033[1;33m

TEST_INIT=mkdir -p ${TMP_DIR} && printf "${COLOR_PINK}$@: ${COLOR_NONE}\n"
TEST_SKIP=printf "${COLOR_BLUE}$@: ${COLOR_YELLOW}Skipped${COLOR_NONE}\n"
TEST_PASS=printf "${COLOR_BLUE}$@: ${COLOR_GREEN}Passed${COLOR_NONE}\n"
TEST_FAIL=(printf "${COLOR_BLUE}$@: ${COLOR_RED}Failed!${COLOR_NONE}\n" && exit 1)

ARGS-sql='select [Loan Number] from data'

LEAKS=
ifneq ($(LEAKS),)
  PREFIX=leaks 2>/dev/null --atExit --
  REDIRECT=>${TMP_DIR}/leaks.txt; grep leak ${TMP_DIR}/leaks.txt | grep bytes \# # stop processing at this step
  REDIRECT1=>${TMP_DIR}/leaks.txt; grep leak ${TMP_DIR}/leaks.txt | grep bytes ) \# # stop processing at this step
  REDIRECT2=>${TMP_DIR}/leaks.txt; grep leak ${TMP_DIR}/leaks.txt | grep bytes ) \# # stop processing at this step
  export CMP=\# # don't run this step
else
  PREFIX=
  REDIRECT=>
  REDIRECT1=>
  REDIRECT2=-o
  ifeq ($(WIN),1)
    # Windows programs end their output lines in \r\n
    CMP ?= diff --strip-trailing-cr
  endif
  CMP ?= cmp
  export CMP
endif

# prefix for invocations whose (nonzero) exit code is asserted: under LEAKS=1,
# leaks(1) owns the exit code, so run them without the leaks wrapper -- but
# still under any required runner PREFIX (e.g. node for emcc, assigned below)
ifneq ($(LEAKS),)
  RC_PREFIX=
else
  RC_PREFIX=${PREFIX}
endif

ifeq ($(CCBN),emcc)
ifeq ($(TESTING),1)
  NODE_EXISTS := $(shell command -v node 2>/dev/null)
  ifeq ($(NODE_EXISTS),)
    $(error "NodeJS not found. Install NodeJS to run wasm tests.")
  endif
  PREFIX="node"
  EXE=.em.js
endif
endif

BIG_FILE ?= none
EXPECT_TIMEOUT ?= 5
EXPECT=TMUX_SOCKET=$@ ../../scripts/test-expect.sh
export EXPECTED_PATH=expected

MAKE_BIN=$(notdir ${MAKE})

DATE_TIME:=$(shell date +%F-%H-%M-%S)
export TIMINGS_CSV:=${TMP_DIR}/timings-${DATE_TIME}.csv

help:
	@echo "To run all tests:"
	@echo "  ${MAKE_BIN} test-all [LEAKS=1]"
	@echo ""
	@echo "Main tests:"
	@echo "  ${MAKE_BIN} test"
	@echo ""
	@echo "Only extension tests:"
	@echo "  ${MAKE_BIN} test-ext"
	@echo ""
	@echo "To run individual test:"
	@echo "  ${MAKE_BIN} test-xxx"
	@echo "where xxx can be: blank-leading-rows ${SOURCES}"
	@echo ""
	@echo "To run on cli:"
	@echo "  ${MAKE_BIN} CLI"

clean:
	rm -rf ${TMP_DIR}
	@${MAKE} -C parallel clean

CLI:
	@echo "Testing CLI..."
	@${MAKE} CLI1=1 test -n | sed 's/\/[^ ]*\/bin\/zsv_/zsv /g' | sh

test-ext:
	(cd ${THIS_MAKEFILE_DIR}/../ext_example && "${MAKE}" CONFIGFILE=${CONFIGFILE} test)

test-all: test test-ext

test: ${TESTS}

test-paste:
	@echo "TO DO: test paste"

${TIMINGS_CSV}:
	@mkdir -p ${TMP_DIR}
	@echo -n "Test, Stage, Time" > ${TIMINGS_CSV}

.SECONDARY: worldcitiespop_mil.csv

.PHONY: help test test-% test-stack clean FORCE

test-prop:
	@${TEST_INIT}
	@EXE=${BUILD_DIR}/bin/zsv_prop${EXE} ${MAKE} -C prop test

ifneq ($(ZSV_NO_PARALLEL),1)
test-parallel: ${BUILD_DIR}/bin/zsv_select${EXE} ${BUILD_DIR}/bin/zsv_count${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@CMP="${CMP}" \
	SELECT_EXE=${BUILD_DIR}/bin/zsv_select${EXE} \
	COUNT_EXE=${BUILD_DIR}/bin/zsv_count${EXE} \
	${MAKE} -C parallel test
endif

test-unrecognized-option: ${TARGETS} ${BUILD_DIR}/bin/zsv_paste${EXE} ${BUILD_DIR}/bin/zsv_check${EXE} ${BUILD_DIR}/bin/zsv_prop${EXE}
	@${TEST_INIT}
	@sh unrecognized-option.sh "${BUILD_DIR}/bin" "${EXE}" "${TMP_DIR}" && ${TEST_PASS} || ${TEST_FAIL}

test-check: test-check-1 test-check-2

test-check-1: ${BUILD_DIR}/bin/zsv_check${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/check.csv -o ${TMP_DIR}/$@.out1 && (echo "should have failed!") || true
	@${CMP} ${TMP_DIR}/$@.out1 expected/$@.out1 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/check.csv --display-row -o ${TMP_DIR}/$@.out2 && (echo "should have failed!" && exit 1) || true
	@${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL}

test-check-2: ${BUILD_DIR}/bin/zsv_check${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --display-row ${TEST_DATA_DIR}/test/latin1.csv -o ${TMP_DIR}/$@.out && (echo "should have failed!") || true
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

# Security regression test for integer underflow vulnerability
test-vuln-cve-underflow: ${TMP_DIR}/test_vuln_cve_underflow${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

${TMP_DIR}/test_vuln_cve_underflow${HOST_EXE}: test_vuln_cve_underflow.c ${BUILD_DIR}/lib/libzsv.a
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} -I../../include test_vuln_cve_underflow.c -L${BUILD_DIR}/lib -lzsv -o $@

ifneq ($(ZSV_EXTRAS),)
  test-limit-rows-pull: ${TMP_DIR}/test_limit_rows_pull${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

  ${TMP_DIR}/test_limit_rows_pull${HOST_EXE}: test_limit_rows_pull.c ${BUILD_DIR}/lib/libzsv.a
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} -I../../include test_limit_rows_pull.c -L${BUILD_DIR}/lib -lzsv -o $@
endif

# -----------------------------------------------------------------------------
# Sanitizer-driven reproduction of the integer-underflow investigation.
#
#   make test-vuln-asan-repro
#     Builds sec/repro.c with the parser unity TU under
#     -fsanitize=address,undefined and runs it against every payload in
#     sec/poc/. Any sanitizer report aborts the process and fails the target.
#
#   make test-vuln-asan-fuzz [FUZZ_ITERS=N]
#     Builds sec/fuzz.c (a portable in-process random-mutation fuzzer that
#     does not require libFuzzer or AFL toolchains), seeds it from sec/poc/,
#     and runs FUZZ_ITERS iterations under ASan+UBSan. Default 1,000,000.
#
# Neither target is part of the default `make test`. Both require a clang
# with sanitizer runtimes (override via `make ... SAN_CC=/path/to/clang`).
# -----------------------------------------------------------------------------
SAN_CC ?= clang
SAN_BUILD_FLAGS = -fsanitize=address,undefined -fno-omit-frame-pointer \
                  -fno-sanitize-recover=all -O1 -g -std=gnu11 -fsigned-char \
                  -DZSV_VERSION=\"sec-review\" -DZSV_HAVE_NEON \
                  -DHAVE_MEMMEM -DHAVE___BUILTIN_EXPECT \
                  -I${THIS_LIB_BASE}/include -I${THIS_LIB_BASE}/src \
                  -I${THIS_LIB_BASE}/app/external/sqlite3
FUZZ_ITERS ?= 1000000

${TMP_DIR}/sec_repro: sec/repro.c ${THIS_LIB_BASE}/src/zsv.c
	@mkdir -p ${TMP_DIR}
	@${SAN_CC} ${SAN_BUILD_FLAGS} ${THIS_LIB_BASE}/src/zsv.c sec/repro.c -o $@

${TMP_DIR}/sec_fuzz: sec/fuzz.c ${THIS_LIB_BASE}/src/zsv.c
	@mkdir -p ${TMP_DIR}
	@${SAN_CC} ${SAN_BUILD_FLAGS} ${THIS_LIB_BASE}/src/zsv.c sec/fuzz.c -o $@

${TMP_DIR}/sec_repro_finish_oob: sec/repro_finish_cell_dl_OOB.c ${THIS_LIB_BASE}/src/zsv.c
	@mkdir -p ${TMP_DIR}
	@${SAN_CC} ${SAN_BUILD_FLAGS} ${THIS_LIB_BASE}/src/zsv.c sec/repro_finish_cell_dl_OOB.c -o $@

${TMP_DIR}/sec_repro_fast_nonstandard_oob: sec/repro_fast_nonstandard_OOB.c ${THIS_LIB_BASE}/src/zsv.c
	@mkdir -p ${TMP_DIR}
	@${SAN_CC} ${SAN_BUILD_FLAGS} ${THIS_LIB_BASE}/src/zsv.c sec/repro_fast_nonstandard_OOB.c -o $@

test-vuln-asan-repro: ${TMP_DIR}/sec_repro
	@${TEST_INIT}
	@for f in sec/poc/*.bin; do \
	  printf "  -- %s --\n" "$$f"; \
	  ${TMP_DIR}/sec_repro "$$f" || ${TEST_FAIL}; \
	done
	@${TEST_PASS}

# Tight-buffer regression for the zsv_finish->cell_dl heap-buffer-overflow.
# The PoC payload is generated programmatically inside the binary so the
# repro is self-contained.
test-vuln-finish-cell-dl-oob: ${TMP_DIR}/sec_repro_finish_oob
	@${TEST_INIT}
	@${TMP_DIR}/sec_repro_finish_oob && ${TEST_PASS} || ${TEST_FAIL}

# Regression for the FAST SIMD scanner OOB on non-standard CSV (mid-cell
# quote that fills the buffer with no row terminator). Verifies that:
#   (a) ASan does not report heap-buffer-overflow in zsv_finish, AND
#   (b) the fast engine returns zsv_status_nonstandard_csv (detection on).
test-vuln-fast-nonstandard-oob: ${TMP_DIR}/sec_repro_fast_nonstandard_oob
	@${TEST_INIT}
	@${TMP_DIR}/sec_repro_fast_nonstandard_oob && ${TEST_PASS} || ${TEST_FAIL}

test-vuln-asan-fuzz: ${TMP_DIR}/sec_fuzz
	@${TEST_INIT}
	@printf "  fuzzing parser for ${FUZZ_ITERS} iterations under ASan+UBSan\n"
	@${TMP_DIR}/sec_fuzz sec/poc ${FUZZ_ITERS} || ${TEST_FAIL}
	@${TEST_PASS}

# Security regression: a malicious JSON index "on" expression must not be able to
# inject extra SQL statements (advisory GHSA-98m3-r6j4-7mpw). Pre-fix, the
# unescaped %s fed to sqlite3_exec() ran every ;-separated statement; here the
# injected second statement is a CREATE TABLE whose presence is the observable
# side effect. Post-fix the multi-statement is refused (non-zero exit) and the
# injected statement never runs.
test-vuln-2db-injection: ${BUILD_DIR}/bin/zsv_2db${EXE}
	@${TEST_INIT}
	@rm -f ${TMP_DIR}/$@.db
	@printf '%s\n' '[{"columns":[{"name":"id"}],"indexes":{"ix1":{"on":"id); CREATE TABLE injected(x); --"}}},[["1"]]]' > ${TMP_DIR}/$@.json
	@# the injection must be refused (non-zero exit)
	@(! ${PREFIX} ${BUILD_DIR}/bin/zsv_2db${EXE} -o ${TMP_DIR}/$@.db --overwrite --from-json --table mytable ${TMP_DIR}/$@.json >/dev/null 2>&1) && ${TEST_PASS} || ${TEST_FAIL}
	@# and the injected statement must not have executed (no "injected" table)
	@sqlite3 ${TMP_DIR}/$@.db "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='injected'" 2>/dev/null > ${TMP_DIR}/$@.chk || printf '0\n' > ${TMP_DIR}/$@.chk
	@printf '0\n' | ${CMP} - ${TMP_DIR}/$@.chk && ${TEST_PASS} || ${TEST_FAIL}

# Security regression: concurrent cursors over one CSV vtab (self join /
# correlated subquery) must keep independent scan state (GHSA-6gxc-p95v-24wg).
# Pre-fix all cursors shared one parser, so a self cross join of a 5-row table
# returned 5 rows instead of 25 (and a correlated subquery hit a
# heap-use-after-free); post-fix each cursor owns its parser. The deterministic
# count check catches the regression without a sanitizer.
test-vuln-sql-crossjoin: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'id,value\n1,A\n2,B\n3,C\n4,D\n5,E\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_sql${EXE} --memory ${TMP_DIR}/$@.csv 'select count(*) from data a cross join data b' > ${TMP_DIR}/$@.cross.out 2>/dev/null
	@printf 'count(*)\n25\n' | ${CMP} - ${TMP_DIR}/$@.cross.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} ${BUILD_DIR}/bin/zsv_sql${EXE} --memory ${TMP_DIR}/$@.csv 'select a.id,(select b.value from data b where b.id=a.id) v from data a order by cast(a.id as integer)' > ${TMP_DIR}/$@.corr.out 2>/dev/null
	@printf 'id,v\n1,A\n2,B\n3,C\n4,D\n5,E\n' | ${CMP} - ${TMP_DIR}/$@.corr.out && ${TEST_PASS} || ${TEST_FAIL}

# Security regression: unit-test the real zsv_sql_authorizer read-only policy
# used by `zsv sql` to sandbox uncontrolled user SQL (CodeQL cpp/sql-injection
# defense-in-depth). Verifies SELECT/READ/FUNCTION/RECURSIVE are allowed and
# every write/DDL/ATTACH action is denied.
test-vuln-sql-readonly: ${TMP_DIR}/test_sql_authorizer${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

${TMP_DIR}/test_sql_authorizer${HOST_EXE}: test_sql_authorizer.c ../sql_authorizer.h
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} -I.. test_sql_authorizer.c -o $@

test-overwrite: ${BUILD_DIR}/bin/zsv_overwrite${EXE} ${BUILD_DIR}/bin/zsv_echo${EXE} ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@CMP="${CMP}" \
	EXE=${BUILD_DIR}/bin/zsv_overwrite${EXE} \
	ECHO_EXE=${BUILD_DIR}/bin/zsv_echo${EXE} \
	SQL_EXE=${BUILD_DIR}/bin/zsv_sql${EXE} \
	${MAKE} -C overwrite test

test-echo: \
	test-echo1 test-echo-overwrite-all test-echo-eol test-echo-chars test-echo-trim \
	test-echo-skip-until test-echo-contiguous test-echo-trim-columns test-echo-trim-columns-2 test-echo-buffsize \
	test-echo-trim-columns-pad test-echo-quoted test-echo-rows

test-echo-rows: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/6x3.csv --start-row 3 ${REDIRECT} ${TMP_DIR}/$@.out1
	@${CMP} ${TMP_DIR}/$@.out1 expected/$@.out1 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/6x3.csv --end-row 3 ${REDIRECT} ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/6x3.csv --between-row 2 4 ${REDIRECT} ${TMP_DIR}/$@.out3
	@${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL}

test-echo-overwrite-all: test-echo-overwrite test-echo-overwrite-csv test-echo-overwrite-auto

test-echo1: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-eol: test-echo-eol-1 test-echo-eol-2 test-echo-eol-3 test-echo-eol-4

test-echo-eol-%: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/no-eol-$*.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-trim: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --trim ${TEST_DATA_DIR}/test/echo-trim.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-skip-until: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --skip-until ASF ${TEST_DATA_DIR}/test/echo-skip-until.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-contiguous: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --contiguous ${TEST_DATA_DIR}/test/../../data/test/echo-contiguous.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-trim-columns: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --trim-columns ${TEST_DATA_DIR}/test/echo-trim-columns.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-trim-columns-2: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --trim --trim-columns ${TEST_DATA_DIR}/test/echo-trim-columns.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

# short rows (incl. header) are padded with blank columns so every output row has equal width
test-echo-trim-columns-pad: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --trim-columns ${TEST_DATA_DIR}/test/echo-trim-columns-pad.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --trim-columns --start-row 2 ${TEST_DATA_DIR}/test/echo-trim-columns-pad.csv ${REDIRECT} ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL}

test-echo-quoted: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/echo-quoted.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-buffsize: ${BUILD_DIR}/bin/zsv_echo${EXE} ${TEST_DATA_DIR}/bigger-than-buff.csv
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/bigger-than-buff.csv --buff-size 131072 ${REDIRECT} ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err
	@(${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${CMP} ${TMP_DIR}/$@.err expected/$@.err) && ${TEST_PASS} || ${TEST_FAIL}

test-echo-chars: ${BUILD_DIR}/bin/zsv_echo${EXE}
	@${TEST_INIT}
	@echo '東京都' | ${PREFIX} $< -u '?' ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-echo-overwrite: ${BUILD_DIR}/bin/zsv_echo${EXE}
ifneq ($(ZSV_EXTRAS),)
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv --overwrite 'sqlite3://${TEST_DATA_DIR}/loans_1-overwrite.db?sql=select row,col,value from overwrites order by row,col' ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
endif

test-echo-overwrite-auto: ${BUILD_DIR}/bin/zsv_echo${EXE}
ifneq ($(ZSV_EXTRAS),)
	@${TEST_INIT}
	@rm -f ${TEST_DATA_DIR}/.zsv/data/loans_2.csv/overwrite.sqlite3
	@cp -p ${TEST_DATA_DIR}/loans_1.csv ${TEST_DATA_DIR}/loans_2.csv
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_2.csv ${REDIRECT} ${TMP_DIR}/$@-before.out
	@${CMP} ${TMP_DIR}/$@-before.out expected/$@-before.out && ${TEST_PASS} || ${TEST_FAIL}
	@mkdir -p ${TEST_DATA_DIR}/.zsv/data/loans_2.csv
	@sqlite3 ${TEST_DATA_DIR}/.zsv/data/loans_2.csv/overwrite.sqlite3 < ${TEST_DATA_DIR}/loans_2_overwrite.sql
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_2.csv ${REDIRECT} ${TMP_DIR}/$@-after-ignored.out
	@${CMP} ${TMP_DIR}/$@-after-ignored.out expected/$@-after-ignored.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_2.csv --apply-overwrites ${REDIRECT} ${TMP_DIR}/$@-after-applied.out
	@${CMP} ${TMP_DIR}/$@-after-applied.out expected/$@-after-applied.out && ${TEST_PASS} || ${TEST_FAIL}
endif

test-echo-overwrite-csv: ${BUILD_DIR}/bin/zsv_echo${EXE} ${TEST_DATA_DIR}/loans_1-overwrite.csv
ifneq ($(ZSV_EXTRAS),)
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv --overwrite '${TEST_DATA_DIR}/loans_1-overwrite.csv' ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
endif

worldcitiespop_mil.csv:
	curl -LOk 'https://burntsushi.net/stuff/worldcitiespop_mil.csv'

worldcitiespop_mil.tsv: worldcitiespop_mil.csv ${BUILD_DIR}/bin/zsv_2tsv${EXE}
	@${BUILD_DIR}/bin/zsv_2tsv${EXE} $< > $@

test-count test-count-pull: test-% : test-1-% test-2-%

ifeq ($(ZSV_NO_PARALLEL),1)
  ifeq ($(HAVE_PCRE2_8),1)
    HELP_SELECT_LINES=52
    HELP_COUNT_LINES=6
  else
    HELP_SELECT_LINES=50
    HELP_COUNT_LINES=6
  endif
else
  ifeq ($(HAVE_PCRE2_8),1)
    HELP_SELECT_LINES=54
    HELP_COUNT_LINES=8
  else
    HELP_SELECT_LINES=53
    HELP_COUNT_LINES=8
  endif
endif
test-cli: ${CLI}
	@${TEST_INIT}
	@[ "${CLI}" = "" ] && echo 1>&2 'test-cli: missing CLI env var' && exit 1 || exit 0
	@${PREFIX} $< help select 2>&1 > ${TMP_DIR}/$@.out
# help/usage now prefixes the command with the host program name (e.g. `zsv select`,
# or whatever argv[0]/$$ZSV_PROG_NAME resolves to), so match the text as a suffix.
	@head -1 ${TMP_DIR}/$@.out | tr -d '\r\n' | grep -qE 'select: extracts and outputs specified columns$$' && [ $$(( `cat ${TMP_DIR}/$@.out | wc -l` )) = "${HELP_SELECT_LINES}" ] && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< help count 2>&1 > ${TMP_DIR}/$@.out
	@head -1 ${TMP_DIR}/$@.out | tr -d '\r\n' | grep -qE 'Usage: .*count \[options\]$$' && [ $$(( `cat ${TMP_DIR}/$@.out | wc -l` )) = "${HELP_COUNT_LINES}" ] && ${TEST_PASS} || ${TEST_FAIL}

test-1-count test-1-count-pull: test-1-% : ${BUILD_DIR}/bin/zsv_%${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@cat worldcitiespop_mil.csv | ${PREFIX} $< ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-1-count.out && ${TEST_PASS} || ${TEST_FAIL}
	@cat worldcitiespop_mil.csv | ${PREFIX} $< --parser fast ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-1-count.out && ${TEST_PASS} || ${TEST_FAIL}

test-2-count test-2-count-pull: ${BUILD_DIR}/bin/zsv_count${EXE} ${TEST_DATA_DIR}/test/buffsplit_quote.csv
	@${TEST_INIT}
	@for x in 5000 5002 5004 5006 5008 5010 5013 5015 5017 5019 5021 5101 5105 5111 5113 5115 5117 5119 5121 5123 5125 5127 5129 5131 5211 5213 5215 5217 5311 5313 5315 5317 5413 5431 5433 5455 6133; do \
	${PREFIX} $< -r $$x ${TEST_DATA_DIR}/test/buffsplit_quote.csv; \
	done > ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-2-count.out && ${TEST_PASS} || ${TEST_FAIL}

# Test nonstandard quoting: compat parser always, fast parser never
# Verifies count and select produce correct output for CSV with mid-cell quotes,
# standard quoted fields with commas, multiline quoted fields, and mixed patterns.
test-quoted-nonstandard: ${BUILD_DIR}/bin/zsv_count${EXE} ${BUILD_DIR}/bin/zsv_select${EXE} ${TEST_DATA_DIR}/test/quoted_nonstandard.csv
	@${TEST_INIT}
	@${PREFIX} ${BUILD_DIR}/bin/zsv_count${EXE} --parser compat ${TEST_DATA_DIR}/test/quoted_nonstandard.csv ${REDIRECT} ${TMP_DIR}/$@-count-compat.out
	@${CMP} ${TMP_DIR}/$@-count-compat.out expected/test-quoted-nonstandard-count.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} ${BUILD_DIR}/bin/zsv_select${EXE} --parser compat ${TEST_DATA_DIR}/test/quoted_nonstandard.csv ${REDIRECT} ${TMP_DIR}/$@-select-compat.out
	@${CMP} ${TMP_DIR}/$@-select-compat.out expected/test-quoted-nonstandard-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-select test-select-pull: test-% : test-onlycrlf-% test-n-% test-6-% test-7-% test-8-% test-9-% test-10-% test-11-% test-12-% test-13-% test-search-% test-regex-% test-quotebuff-% test-fixed-1-% test-fixed-2-% test-fixed-3-% test-fixed-4-% test-merge-% ${STDIN_FILENAME_SELECT_TESTS} test-corrupt-props-%

# --stdin-filename: apply file properties saved under the given path to stdin input;
# the path need not exist (only its .zsv/data cache does), an explicit
# command-line option still overrides the saved property, and an input read
# from an actual file keeps its own property lookup
test-stdin-filename-select test-stdin-filename-select-pull: test-stdin-filename-% : ${BUILD_DIR}/bin/zsv_%${EXE} ${BUILD_DIR}/bin/zsv_prop${EXE}
	@${TEST_INIT}
	@mkdir -p ${TMP_DIR}/$@
	@printf 'Col,Type\nA,B\n1,x\n2,y\n' > ${TMP_DIR}/$@/data.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TMP_DIR}/$@/file.csv -d 2 >/dev/null
	@h=`${PREFIX} $< --stdin-filename ${TMP_DIR}/$@/file.csv < ${TMP_DIR}/$@/data.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "Col A,Type B" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --stdin-filename ${TMP_DIR}/$@/file.csv - < ${TMP_DIR}/$@/data.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "Col A,Type B" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --stdin-filename ${TMP_DIR}/$@/file.csv -d 1 < ${TMP_DIR}/$@/data.csv 2>/dev/null | head -1 | ${STRIP_CR}`; [ "$$h" = "Col,Type" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --stdin-filename ${TMP_DIR}/$@/file.csv ${TMP_DIR}/$@/data.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "Col,Type" ] && ${TEST_PASS} || ${TEST_FAIL}
	@${RC_PREFIX} $< --stdin-filename >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}
	@${RC_PREFIX} $< --stdin-filename - >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}

# a corrupt .zsv/data property cache must fail loudly (nonzero exit + message
# naming the props file), not silently exit 0 with no output
test-corrupt-props-select test-corrupt-props-select-pull: test-corrupt-props-% : ${BUILD_DIR}/bin/zsv_%${EXE} ${BUILD_DIR}/bin/zsv_prop${EXE}
	@${TEST_INIT}
	@rm -rf ${TMP_DIR}/$@ && mkdir -p ${TMP_DIR}/$@
	@printf 'Col,Type\nA,B\n1,x\n' > ${TMP_DIR}/$@/file.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TMP_DIR}/$@/file.csv -d 2 >/dev/null
	@echo 'not json' > ${TMP_DIR}/$@/.zsv/data/file.csv/props.json
	@${RC_PREFIX} $< ${TMP_DIR}/$@/file.csv > ${TMP_DIR}/$@/out.txt 2> ${TMP_DIR}/$@/err.txt; rc=$$?; \
	  grep -q 'unable to parse properties file' ${TMP_DIR}/$@/err.txt \
	    && [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ! [ -s ${TMP_DIR}/$@/out.txt ] && ${TEST_PASS} || ${TEST_FAIL}
# under LEAKS=1 the line above runs unwrapped, so leak-gate the error path here
	@(${PREFIX} $< ${TMP_DIR}/$@/file.csv 2>/dev/null ${REDIRECT1} ${TMP_DIR}/$@/leakchk.out) || true

# sql buffers stdin to a temp file; --stdin-filename must still apply saved props
test-stdin-filename-sql: ${BUILD_DIR}/bin/zsv_sql${EXE} ${BUILD_DIR}/bin/zsv_prop${EXE}
	@${TEST_INIT}
	@mkdir -p ${TMP_DIR}/$@
	@printf 'Col,Type\nA,B\n1,x\n2,y\n' > ${TMP_DIR}/$@/data.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TMP_DIR}/$@/file.csv -d 2 >/dev/null
	@h=`${PREFIX} $< --stdin-filename ${TMP_DIR}/$@/file.csv - 'select * from data' < ${TMP_DIR}/$@/data.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "Col A,Type B" ] && ${TEST_PASS} || ${TEST_FAIL}

test-merge-select test-merge-select-pull: test-merge-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< --merge ${TEST_DATA_DIR}/test/select-merge.csv ${REDIRECT} ${TMP_DIR}/test-merge-%.out
	@${CMP} ${TMP_DIR}/test-merge-%.out expected/test-merge-select.out && ${TEST_PASS} || ${TEST_FAIL}

# --rename: targeted column rename by name (renames every match) or #N (1-based input index; the only
# way to target a blank-named or one of several duplicate-named columns). Header-only and streaming:
# data rows are byte-identical, and a selector matching no column / an out-of-range index is a hard
# error with non-zero exit. See SPEC-zsv-select-rename.md and the f1-rename-tests.make AC harness.
test-rename-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@printf 'id,name,amount\n1,a,10\n2,b,20\n' > ${TMP_DIR}/$@-r.csv
	@printf 'id,,amount\n1,x,10\n2,y,20\n' > ${TMP_DIR}/$@-blank.csv
	@printf 'id,name,name,amount\n1,a,b,10\n2,c,d,20\n' > ${TMP_DIR}/$@-dup.csv
	@h=`${PREFIX} $< --rename 'name=full_name' ${TMP_DIR}/$@-r.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,full_name,amount" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '#3=amt' ${TMP_DIR}/$@-r.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,name,amt" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '#2=subtype' ${TMP_DIR}/$@-blank.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,subtype,amount" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename 'name=n' ${TMP_DIR}/$@-dup.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,n,n,amount" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '#2=first_name' ${TMP_DIR}/$@-dup.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,first_name,name,amount" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< -n --rename '#2=subtype' ${TMP_DIR}/$@-blank.csv -- 1 2 | head -1 | ${STRIP_CR}`; [ "$$h" = "id,subtype" ] && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --rename 'name=full_name' ${TMP_DIR}/$@-r.csv | tail -n +2 > ${TMP_DIR}/$@-data.out; tail -n +2 ${TMP_DIR}/$@-r.csv > ${TMP_DIR}/$@-data.exp; ${CMP} ${TMP_DIR}/$@-data.out ${TMP_DIR}/$@-data.exp && ${TEST_PASS} || ${TEST_FAIL}
	@${RC_PREFIX} $< --rename 'nope=x' ${TMP_DIR}/$@-r.csv >/dev/null 2>${TMP_DIR}/$@-err.txt; rc=$$?; grep -q nope ${TMP_DIR}/$@-err.txt && [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}
	@${RC_PREFIX} $< --rename '#9=x' ${TMP_DIR}/$@-r.csv >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}
# hash-escape addendum (AC14-AC18): address columns whose name begins with '#'
	@printf 'id,#foo,amt\n1,a,10\n' > ${TMP_DIR}/$@-hf.csv
	@printf 'id,#5,amt\n1,a,10\n' > ${TMP_DIR}/$@-h5.csv
	@h=`${PREFIX} $< --rename '\#foo=x' ${TMP_DIR}/$@-hf.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,x,amt" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '#foo=x' ${TMP_DIR}/$@-hf.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,x,amt" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '\#5=NEW' ${TMP_DIR}/$@-h5.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,NEW,amt" ] && ${TEST_PASS} || ${TEST_FAIL}
	@h=`${PREFIX} $< --rename '#2=b' ${TMP_DIR}/$@-r.csv | head -1 | ${STRIP_CR}`; [ "$$h" = "id,b,amount" ] && ${TEST_PASS} || ${TEST_FAIL}
	@${RC_PREFIX} $< --rename '#5=oops' ${TMP_DIR}/$@-h5.csv >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}
# unmatched escaped selector reports the RESOLVED literal name (#9), not the raw arg (\#9)
	@${RC_PREFIX} $< --rename '\#9=x' ${TMP_DIR}/$@-hf.csv >/dev/null 2>${TMP_DIR}/$@-err2.txt; rc=$$?; grep -q '"#9"' ${TMP_DIR}/$@-err2.txt && [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}
# F1b: a header-phase --rename error must NOT leave a 0-byte -o output file behind
	@rm -f ${TMP_DIR}/$@-oerr.csv; ${RC_PREFIX} $< --rename '#9=x' ${TMP_DIR}/$@-r.csv -o ${TMP_DIR}/$@-oerr.csv >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && [ ! -f ${TMP_DIR}/$@-oerr.csv ] && ${TEST_PASS} || ${TEST_FAIL}
# F1b: same for a bad column selector (the deferred-open fix covers all header-phase errors)
	@rm -f ${TMP_DIR}/$@-oselerr.csv; ${RC_PREFIX} $< ${TMP_DIR}/$@-r.csv -o ${TMP_DIR}/$@-oselerr.csv -- nope >/dev/null 2>&1; rc=$$?; [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && [ ! -f ${TMP_DIR}/$@-oselerr.csv ] && ${TEST_PASS} || ${TEST_FAIL}
# F1b: a pre-existing -o target is preserved (not truncated) on a header-phase error
	@printf 'KEEP\n' > ${TMP_DIR}/$@-okeep.csv; ${PREFIX} $< --rename '#9=x' ${TMP_DIR}/$@-r.csv -o ${TMP_DIR}/$@-okeep.csv >/dev/null 2>&1; g=`cat ${TMP_DIR}/$@-okeep.csv 2>/dev/null`; [ "$$g" = "KEEP" ] && ${TEST_PASS} || ${TEST_FAIL}
# F1b: a successful --rename with -o still writes the file with the renamed header
	@rm -f ${TMP_DIR}/$@-ook.csv; ${PREFIX} $< --rename 'name=full_name' ${TMP_DIR}/$@-r.csv -o ${TMP_DIR}/$@-ook.csv >/dev/null 2>&1; h=`head -1 ${TMP_DIR}/$@-ook.csv 2>/dev/null | ${STRIP_CR}`; [ "$$h" = "id,full_name,amount" ] && ${TEST_PASS} || ${TEST_FAIL}

# Under WSL a Windows executable receives stdin redirected from a file as a pipe, so the
# test-sample-select checks that redirect a file into stdin also accept the pipe result there;
# the comparison with the file result, which is expected to fail, is silenced
SAMPLE_OR_PIPE=$(if $(filter 1,${WIN}),>/dev/null || ${CMP} $(1) $(2),)

# --sample-size: n rows chosen uniformly at random, in input order, from the rows passing -D/-H/-s.
# A regular file (or stdin redirected from one) is counted then sampled (Algorithm S); a pipe is
# reservoir-sampled (Algorithm R), so the two yield different samples for the same --seed and have
# separate fixtures. Input rows are `id,x` with id == data row number, so order/subset checks are shell
test-sample-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@(echo id,val; seq 1 100 | sed 's/$$/,x/') > ${TMP_DIR}/$@.csv
	@${PREFIX} $< --sample-size 5 --seed 42 ${TMP_DIR}/$@.csv ${REDIRECT} ${TMP_DIR}/$@-file.out
	@${CMP} ${TMP_DIR}/$@-file.out expected/$@-file.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --sample-size 5 --seed 42 < ${TMP_DIR}/$@.csv ${REDIRECT} ${TMP_DIR}/$@-redirect.out
	@${CMP} ${TMP_DIR}/$@-redirect.out expected/$@-file.out $(call SAMPLE_OR_PIPE,${TMP_DIR}/$@-redirect.out,expected/$@-pipe.out) && ${TEST_PASS} || ${TEST_FAIL}
	@cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --sample-size 5 --seed 42 ${REDIRECT} ${TMP_DIR}/$@-pipe.out
	@${CMP} ${TMP_DIR}/$@-pipe.out expected/$@-pipe.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< -s 7 --sample-size 3 --seed 1 ${TMP_DIR}/$@.csv ${REDIRECT} ${TMP_DIR}/$@-search-file.out
	@${CMP} ${TMP_DIR}/$@-search-file.out expected/$@-search-file.out && ${TEST_PASS} || ${TEST_FAIL}
	@cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -s 7 --sample-size 3 --seed 1 ${REDIRECT} ${TMP_DIR}/$@-search-pipe.out
	@${CMP} ${TMP_DIR}/$@-search-pipe.out expected/$@-search-pipe.out && ${TEST_PASS} || ${TEST_FAIL}
# -N reports the original row number on both paths: every data line is "<id>,<id>,x"
	@c=`${PREFIX} $< -N --sample-size 7 ${TMP_DIR}/$@.csv | tail -n +2 | awk -F, '$$1==$$2' | wc -l`; [ "$$c" -eq 7 ] && ${TEST_PASS} || ${TEST_FAIL}
	@c=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -N --sample-size 7 | tail -n +2 | awk -F, '$$1==$$2' | wc -l`; [ "$$c" -eq 7 ] && ${TEST_PASS} || ${TEST_FAIL}
# properties (unseeded): exact count, header, strictly increasing ids, every line present in the input
	@f=0; for in in "${TMP_DIR}/$@.csv" "-"; do \
	  if [ "$$in" = "-" ]; then cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --sample-size 7 > ${TMP_DIR}/$@-prop.out; \
	  else ${PREFIX} $< --sample-size 7 $$in > ${TMP_DIR}/$@-prop.out; fi; \
	  [ `wc -l < ${TMP_DIR}/$@-prop.out` -eq 8 ] && ${TEST_PASS} || { ${TEST_FAIL}; f=1; }; \
	  [ "`head -1 ${TMP_DIR}/$@-prop.out | ${STRIP_CR}`" = "id,val" ] && ${TEST_PASS} || { ${TEST_FAIL}; f=1; }; \
	  tail -n +2 ${TMP_DIR}/$@-prop.out | cut -d, -f1 | awk 'NR>1 && $$1<=p {exit 1} {p=$$1}' && ${TEST_PASS} || { ${TEST_FAIL}; f=1; }; \
	  ${STRIP_CR} < ${TMP_DIR}/$@-prop.out | grep -vFxf ${TMP_DIR}/$@.csv | grep -q . && { ${TEST_FAIL}; f=1; } || ${TEST_PASS}; \
	done; [ $$f -eq 0 ]
# n >= N: identical to the input, on both paths; header-only input: header only
	@${PREFIX} $< --sample-size 1000 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-all-file.out; ${CMP} ${TMP_DIR}/$@-all-file.out ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL}
	@cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --sample-size 1000 > ${TMP_DIR}/$@-all-pipe.out; ${CMP} ${TMP_DIR}/$@-all-pipe.out ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL}
	@h=`printf 'a,b\n' | ${PREFIX} $< --sample-size 3 | ${STRIP_CR}`; [ "$$h" = "a,b" ] && ${TEST_PASS} || ${TEST_FAIL}
# a stream handed over already positioned past byte 0 is counted from that position, not rewound to 0
# (seeded, n < N: a count pass that rewound to byte 0 would count a different population and pick different rows)
	@tail -c +13 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-pos.csv; ${PREFIX} $< --sample-size 5 --seed 3 ${TMP_DIR}/$@-pos.csv > ${TMP_DIR}/$@-pos.exp; $(if $(filter 1,${WIN}),cat ${TMP_DIR}/$@-pos.csv | ${PREFIX} $< --sample-size 5 --seed 3 > ${TMP_DIR}/$@-pos-pipe.exp;) { dd bs=1 count=12 >/dev/null 2>&1; ${PREFIX} $< --sample-size 5 --seed 3; } < ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-pos.out; ${CMP} ${TMP_DIR}/$@-pos.out ${TMP_DIR}/$@-pos.exp $(call SAMPLE_OR_PIPE,${TMP_DIR}/$@-pos.out,${TMP_DIR}/$@-pos-pipe.exp) && [ `wc -l < ${TMP_DIR}/$@-pos.out` -eq 6 ] && ${TEST_PASS} || ${TEST_FAIL}
# -D and -H bound the population on both paths (-H 5 with -D 3 leaves rows 4 and 5)
	@c=`${PREFIX} $< -D 90 --sample-size 20 ${TMP_DIR}/$@.csv | tail -n +2 | awk -F, '$$1>90' | wc -l`; [ "$$c" -eq 10 ] && ${TEST_PASS} || ${TEST_FAIL}
	@c=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -D 90 --sample-size 20 | tail -n +2 | awk -F, '$$1>90' | wc -l`; [ "$$c" -eq 10 ] && ${TEST_PASS} || ${TEST_FAIL}
	@c=`${PREFIX} $< -H 10 --sample-size 20 ${TMP_DIR}/$@.csv | tail -n +2 | awk -F, '$$1<=10' | wc -l`; [ "$$c" -eq 10 ] && ${TEST_PASS} || ${TEST_FAIL}
	@c=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -H 10 --sample-size 20 | tail -n +2 | awk -F, '$$1<=10' | wc -l`; [ "$$c" -eq 10 ] && ${TEST_PASS} || ${TEST_FAIL}
	@r=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -D 3 -H 5 --sample-size 5 | tail -n +2 | cut -d, -f1 | tr '\n' ' '`; [ "$$r" = "4 5 " ] && ${TEST_PASS} || ${TEST_FAIL}
# -H counts rows read, so -D past the -H limit leaves nothing (applies to plain select too)
	@r=`${PREFIX} $< -D 5 -H 3 ${TMP_DIR}/$@.csv | wc -l`; [ "$$r" -eq 1 ] && ${TEST_PASS} || ${TEST_FAIL}
	@r=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -D 5 -H 3 --sample-size 5 | wc -l`; [ "$$r" -eq 1 ] && ${TEST_PASS} || ${TEST_FAIL}
# parses that stop early without error (-L, --parser fast on non-standard input) must behave as plain select: same rows, exit 0
	@${PREFIX} $< -L 4 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-L.exp; ${PREFIX} $< -L 4 --sample-size 100 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-L-file.out; ${CMP} ${TMP_DIR}/$@-L-file.out ${TMP_DIR}/$@-L.exp && ${TEST_PASS} || ${TEST_FAIL}
	@cat ${TMP_DIR}/$@.csv | ${PREFIX} $< -L 4 --sample-size 100 > ${TMP_DIR}/$@-L-pipe.out; ${CMP} ${TMP_DIR}/$@-L-pipe.out ${TMP_DIR}/$@-L.exp && ${TEST_PASS} || ${TEST_FAIL}
	@printf 'a,b\n1,2\n3,4\nx"y,5\n6,7\n' > ${TMP_DIR}/$@-ns.csv
	@${PREFIX} $< --parser fast ${TMP_DIR}/$@-ns.csv > ${TMP_DIR}/$@-ns.exp 2>/dev/null; ${PREFIX} $< --parser fast --sample-size 10 ${TMP_DIR}/$@-ns.csv > ${TMP_DIR}/$@-ns-file.out 2>/dev/null; rc=$$?; [ "$$rc" -eq 0 ] && ${CMP} ${TMP_DIR}/$@-ns-file.out ${TMP_DIR}/$@-ns.exp && ${TEST_PASS} || ${TEST_FAIL}
	@cat ${TMP_DIR}/$@-ns.csv | ${PREFIX} $< --parser fast --sample-size 10 > ${TMP_DIR}/$@-ns-pipe.out 2>/dev/null; ${CMP} ${TMP_DIR}/$@-ns-pipe.out ${TMP_DIR}/$@-ns.exp && ${TEST_PASS} || ${TEST_FAIL}
# -H now bounds rows read for the other samplers too (previously -H 5 --sample-every 3 emitted row 7)
	@r=`${PREFIX} $< -H 5 --sample-every 3 ${TMP_DIR}/$@.csv | tail -n +2 | cut -d, -f1 | tr '\n' ' '`; [ "$$r" = "1 4 " ] && ${TEST_PASS} || ${TEST_FAIL}
# reservoir replay is byte-identical to a plain pass for quoted, multi-line and whitespace-cleaned cells
	@printf 'a,b,c\n" x ","line1\nline2",z\n"q""uote", y \n,\n1\n' > ${TMP_DIR}/$@-q.csv
	@${PREFIX} $< ${TMP_DIR}/$@-q.csv > ${TMP_DIR}/$@-q.exp; cat ${TMP_DIR}/$@-q.csv | ${PREFIX} $< --sample-size 10 > ${TMP_DIR}/$@-q.out; ${CMP} ${TMP_DIR}/$@-q.out ${TMP_DIR}/$@-q.exp && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< -w ${TMP_DIR}/$@-q.csv > ${TMP_DIR}/$@-qw.exp; cat ${TMP_DIR}/$@-q.csv | ${PREFIX} $< -w --sample-size 10 > ${TMP_DIR}/$@-qw.out; ${CMP} ${TMP_DIR}/$@-qw.out ${TMP_DIR}/$@-qw.exp && ${TEST_PASS} || ${TEST_FAIL}
# column selection and --no-header compose with sampling
	@c=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --no-header -n --sample-size 5 -- 2 | ${STRIP_CR} | grep -cx x`; [ "$$c" -eq 5 ] && ${TEST_PASS} || ${TEST_FAIL}
# --seed is reproducible, for --sample-pct too
	@${PREFIX} $< --sample-pct 30 --seed 7 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-pct1.out; ${PREFIX} $< --sample-pct 30 --seed 7 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-pct2.out; ${CMP} ${TMP_DIR}/$@-pct1.out ${TMP_DIR}/$@-pct2.out && [ `wc -l < ${TMP_DIR}/$@-pct1.out` -gt 1 ] && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --sample-pct 30 --seed 8 ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-pct3.out; cmp -s ${TMP_DIR}/$@-pct1.out ${TMP_DIR}/$@-pct3.out && ${TEST_FAIL} || ${TEST_PASS}
	@cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --sample-size 5 --seed 8 > ${TMP_DIR}/$@-seed8.out; ${CMP} ${TMP_DIR}/$@-seed8.out expected/$@-pipe.out >/dev/null && ${TEST_FAIL} || ${TEST_PASS}
# a FIFO has a path but is not rewindable: must take the reservoir path rather than draining it in a count pass.
# Not on Windows, where a Linux FIFO reached through WSL's file share can block the executable
	@if [ "${WIN}" = 1 ]; then ${TEST_SKIP}; elif command -v mkfifo >/dev/null 2>&1; then rm -f ${TMP_DIR}/$@.fifo; mkfifo ${TMP_DIR}/$@.fifo; (cat ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@.fifo &); c=`${PREFIX} $< --sample-size 5 ${TMP_DIR}/$@.fifo | wc -l`; rm -f ${TMP_DIR}/$@.fifo; [ "$$c" -eq 6 ] && ${TEST_PASS} || ${TEST_FAIL}; fi
# a --sample-size far beyond the input must not reserve memory for it up front
	@c=`cat ${TMP_DIR}/$@.csv | ${PREFIX} $< --sample-size 1000000000000 | wc -l`; [ "$$c" -eq 101 ] && ${TEST_PASS} || ${TEST_FAIL}
# errors: non-zero exit (< 128), nothing on stdout
	@f=0; for a in "--sample-size 0" "--sample-size x" "--sample-size 5 -j 2" "--seed 1" "--sample-size 3 --sample-every 2" "--sample-size 3 --sample-pct 5" "--seed 1x --sample-size 3"; do \
	  ${RC_PREFIX} $< $$a ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-err.out 2>/dev/null; rc=$$?; \
	  [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && [ ! -s ${TMP_DIR}/$@-err.out ] && ${TEST_PASS} || { ${TEST_FAIL}; f=1; }; \
	done; [ $$f -eq 0 ]
# the raw-row fast path must not truncate rows wider than 256 columns
	@awk 'BEGIN{for(r=0;r<2;r++){for(i=1;i<=300;i++)printf "%s%d", (i>1?",":""), r*1000+i; print ""}}' > ${TMP_DIR}/$@-wide.csv
	@c=`${PREFIX} $< -W ${TMP_DIR}/$@-wide.csv | tail -1 | tr ',' '\n' | wc -l`; [ "$$c" -eq 300 ] && ${TEST_PASS} || ${TEST_FAIL}

test-rng: ${TMP_DIR}/test_rng${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

${TMP_DIR}/test_rng${HOST_EXE}: test_rng.c ../utils/rng.c
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} ${CFLAGS_AUTO} -I../../include test_rng.c ../utils/rng.c -o $@

test-sheet-edits: ${TMP_DIR}/test_sheet_edits${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

${TMP_DIR}/test_sheet_edits${HOST_EXE}: test_sheet_edits.c ../sheet/edits.c ../sheet/edits.h
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} ${CFLAGS_AUTO} test_sheet_edits.c ../sheet/edits.c -o $@

test-args: ${TMP_DIR}/test_args${HOST_EXE}
	@${TEST_INIT}
	@$< 2>${TMP_DIR}/$@.err && ${TEST_PASS} || (cat ${TMP_DIR}/$@.err; ${TEST_FAIL})

# under ASan and UBSan where SAN_CC can link them, so a read past an arg is a failure
TEST_ARGS_SAN=-fsanitize=address,undefined -fno-sanitize-recover=all
SAN_CC_OK:=$(shell printf 'int main(void){return 0;}\n' | ${SAN_CC} ${TEST_ARGS_SAN} -x c - -o /dev/null 2>/dev/null && echo yes)
ifeq ($(SAN_CC_OK),yes)
TEST_ARGS_CC=${SAN_CC} ${TEST_ARGS_SAN} -g -std=gnu11 -fsigned-char
else
TEST_ARGS_CC=${CC} ${CFLAGS} ${CFLAGS_AUTO}
endif

${TMP_DIR}/test_args${HOST_EXE}: test_args.c ../utils/arg.c ../../include/zsv/utils/arg.h
	@mkdir -p ${TMP_DIR}
	@${TEST_ARGS_CC} -DZSV_EXTRAS -I../../include test_args.c ../utils/arg.c -o $@

test-mkdirs: ${TMP_DIR}/test_mkdirs${HOST_EXE}
	@${TEST_INIT}
	@rm -rf ${TMP_DIR}/$@ && mkdir -p ${TMP_DIR}/$@
	@$< ${TMP_DIR}/$@ && ${TEST_PASS} || ${TEST_FAIL}

# dirs.c is built standalone (no jq/yajl); its headers need the generated zsv.h,
# which src/Makefile produces, so delegate on a clean tree
../../include/zsv.h: ../../include/zsv.h.in
	${MAKE} -C ../../src ../include/zsv.h CONFIGFILE=${CONFIGFILEPATH}

${TMP_DIR}/test_mkdirs${HOST_EXE}: test_mkdirs.c ../utils/dirs.c ../../include/zsv/utils/dirs.h ../../include/zsv.h
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} ${CFLAGS_AUTO} -DZSV_NO_JQ -I../../include test_mkdirs.c ../utils/dirs.c -o $@

test-replace-file: ${TMP_DIR}/test_replace_file${HOST_EXE}
	@${TEST_INIT}
	@$< ${TMP_DIR} 2>${TMP_DIR}/$@.err && ${TEST_PASS} || (cat ${TMP_DIR}/$@.err; ${TEST_FAIL})

# on Windows os.c's long-path helpers call functions in win/io.c, which dirs.c #includes
# along with the other win/ helpers; os.c #includes the rest, so they are prerequisites only
TEST_REPLACE_FILE_SRC=test_replace_file.c ../utils/os.c
TEST_REPLACE_FILE_DEP=../../include/zsv/utils/os.h
ifeq ($(WIN),1)
  # dirs.c, standalone as for test-mkdirs, #includes win/io.c and the long-path helpers
  TEST_REPLACE_FILE_SRC+=../utils/dirs.c
  TEST_REPLACE_FILE_DEP+=../utils/win/io.h ../utils/win/io.c ../utils/win/fopen_longpath.c \
    ../utils/win/remove_longpath.c ../utils/win/dir_exists_longpath.c ../utils/win/mkdir_longpath.c \
    ../../include/zsv/utils/dirs.h ../../include/zsv.h
endif

${TMP_DIR}/test_replace_file${HOST_EXE}: ${TEST_REPLACE_FILE_SRC} ${TEST_REPLACE_FILE_DEP}
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} ${CFLAGS_AUTO} -DZSV_NO_JQ -I../../include ${TEST_REPLACE_FILE_SRC} -o $@

test-strcasestr: ${TMP_DIR}/test_strcasestr${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

# string.c is built standalone (no libzsv) with the utf8proc the app links; the parent
# Makefile extracts it, so delegate on a clean tree as the zsv_% rule does
UTF8PROC_SRC=$(realpath ..)/external/utf8proc-2.11.3
${UTF8PROC_SRC}/utf8proc.c:
	${MAKE} -C .. $@ CONFIGFILE=${CONFIGFILEPATH} DEBUG=${DEBUG}

${TMP_DIR}/test_strcasestr${HOST_EXE}: test_strcasestr.c ../utils/string.c ${UTF8PROC_SRC}/utf8proc.c
	@mkdir -p ${TMP_DIR}
	@${CC} ${CFLAGS} ${CFLAGS_AUTO} -I../../include -I${UTF8PROC_SRC} -DUTF8PROC_STATIC \
	  -DZSV_UTILS_STRING_STANDALONE -DZSV_STRING_LIB_ONLY \
	  test_strcasestr.c ../utils/string.c ${UTF8PROC_SRC}/utf8proc.c -o $@

test-sheet-cell-overlay: ${TMP_DIR}/test_sheet_cell_overlay${HOST_EXE}
	@${TEST_INIT}
	@$< && ${TEST_PASS} || ${TEST_FAIL}

# cell-overlay.c is plain C plus the utf8proc the app links, but it reaches curses through
# sheet/terminal.h, so the curses flags and library build and link it
CELL_OVERLAY_SRC=test_sheet_cell_overlay.c ../sheet/cell-overlay.c ${UTF8PROC_SRC}/utf8proc.c
CELL_OVERLAY_CFLAGS=${CFLAGS} ${CFLAGS_AUTO} ${CFLAGS_NCURSES} -I${UTF8PROC_SRC} -DUTF8PROC_STATIC
ifeq ($(SAN_CC_OK),yes)
CELL_OVERLAY_CC=${SAN_CC} ${TEST_ARGS_SAN} -g -std=gnu11 -fsigned-char
else
CELL_OVERLAY_CC=${CC}
endif

${TMP_DIR}/test_sheet_cell_overlay${HOST_EXE}: ${CELL_OVERLAY_SRC} ../sheet/cell-overlay.h \
		../sheet/sheet_internal.h ../sheet/terminal.h ../sheet/curses.h
	@mkdir -p ${TMP_DIR}
	@${CELL_OVERLAY_CC} ${CELL_OVERLAY_CFLAGS} ${CELL_OVERLAY_SRC} -o $@ ${LDFLAGS_NCURSES}

test-sheet-status-message: ${TMP_DIR}/test_sheet_status_message${HOST_EXE}
	@${TEST_INIT}
	@env -u ZSVSHEET_STATUS_TIMEOUT_MS $< 4000 && \
	env ZSVSHEET_STATUS_TIMEOUT_MS=1234 $< 1234 && \
	env ZSVSHEET_STATUS_TIMEOUT_MS=abc $< 4000 && \
	env ZSVSHEET_STATUS_TIMEOUT_MS=4294967297 $< 4294967295 && ${TEST_PASS} || ${TEST_FAIL}

# status-message.c is plain C (no curses, no other sheet source), so it links on its own
STATUS_MESSAGE_SRC=test_sheet_status_message.c ../sheet/status-message.c
ifeq ($(SAN_CC_OK),yes)
STATUS_MESSAGE_CC=${SAN_CC} ${TEST_ARGS_SAN} -g -std=gnu11 -fsigned-char
else
STATUS_MESSAGE_CC=${CC}
endif

${TMP_DIR}/test_sheet_status_message${HOST_EXE}: ${STATUS_MESSAGE_SRC} ../sheet/status-message.h
	@mkdir -p ${TMP_DIR}
	@${STATUS_MESSAGE_CC} ${CFLAGS} ${CFLAGS_AUTO} ${STATUS_MESSAGE_SRC} -o $@

test-sheet-clipboard: ${TMP_DIR}/test_sheet_clipboard${HOST_EXE}
	@${TEST_INIT}
	@$< ${TMP_DIR}/$@ && ${TEST_PASS} || ${TEST_FAIL}

# clipboard.c hands the text to a tool with fork/exec, and needs no curses; the test puts fake
# tools on the module's own path. Its budget uses zsv_now_ms() from zsv/utils/os
CLIPBOARD_SRC=test_sheet_clipboard.c ../sheet/clipboard.c ../utils/os.c
CLIPBOARD_CFLAGS=${CFLAGS} ${CFLAGS_AUTO} -I${THIS_LIB_BASE}/include
ifeq ($(SAN_CC_OK),yes)
CLIPBOARD_CC=${SAN_CC} ${TEST_ARGS_SAN} -g -std=gnu11 -fsigned-char
else
CLIPBOARD_CC=${CC}
endif

${TMP_DIR}/test_sheet_clipboard${HOST_EXE}: ${CLIPBOARD_SRC} ../sheet/clipboard.h
	@mkdir -p ${TMP_DIR}
	@${CLIPBOARD_CC} ${CLIPBOARD_CFLAGS} ${CLIPBOARD_SRC} -o $@

test-quotebuff-select test-quotebuff-select-pull: test-quotebuff-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${THIS_MAKEFILE_DIR}/select-quotebuff-gen.sh | ${PREFIX} $< -B 4096 ${REDIRECT} ${TMP_DIR}/$@.out
# use a dummy ${CMP} call so that it works with LEAKS=1
	@${CMP} /dev/null /dev/null && sed 's/"/Q/g' < ${TMP_DIR}/$@.out | grep QQ >/dev/null && ${TEST_FAIL} || ${TEST_PASS}

test-n-select test-n-select-pull: test-n-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}

	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv -u "?" -R 4 -d 2 ${REDIRECT} ${TMP_DIR}/test-n-$*.out
	@${CMP} ${TMP_DIR}/test-n-$*.out expected/test-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv -u "?" -H 10 -R 4 -d 2 -x 'Cash Out Amount' ${REDIRECT} ${TMP_DIR}/test-n-$*-x.out
	@${CMP} ${TMP_DIR}/test-n-$*-x.out expected/test-select-x.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/test/embedded.csv -e 'X' ${REDIRECT} ${TMP_DIR}/test-2-$*.out
	@${CMP} ${TMP_DIR}/test-2-$*.out expected/test-2-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/test/embedded_dos.csv -e 'X' ${REDIRECT} ${TMP_DIR}/test-3-$*.out
	@${CMP} ${TMP_DIR}/test-3-$*.out expected/test-3-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/loans_1.csv -u "?" -R 4 -d 2 -N ${REDIRECT} ${TMP_DIR}/test-4-$*.out
	@${CMP} ${TMP_DIR}/test-4-$*.out expected/test-4-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/quoted.csv -e 'x' ${REDIRECT} ${TMP_DIR}/test-5-$*.out
	@${CMP} ${TMP_DIR}/test-5-$*.out expected/test-5-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/stack1.csv --no-header -H 10 ${REDIRECT} ${TMP_DIR}/test-no-header-$*.out
	@${CMP} ${TMP_DIR}/test-no-header-$*.out expected/test-no-header-select.out && ${TEST_PASS} || ${TEST_FAIL}

	@${PREFIX} $< ${TEST_DATA_DIR}/stack1.csv --prepend-header 'my,Header.' -H 10 ${REDIRECT} ${TMP_DIR}/test-prepend-header-$*.out
	@${CMP} ${TMP_DIR}/test-prepend-header-$*.out expected/test-prepend-header-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-6-select test-6-select-pull: test-6-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/tab.txt -t ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-6-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-7-select test-7-select-pull: test-7-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/white.csv ${REDIRECT} ${TMP_DIR}/$@.out1
	@${CMP} ${TMP_DIR}/$@.out1 expected/test-7-select.out1 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --whitespace-clean ${TEST_DATA_DIR}/test/white.csv ${REDIRECT} ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/test-7-select.out2 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --whitespace-clean-no-newline ${TEST_DATA_DIR}/test/white.csv ${REDIRECT} ${TMP_DIR}/$@.out3
	@${CMP} ${TMP_DIR}/$@.out3 expected/test-7-select.out3 && ${TEST_PASS} || ${TEST_FAIL}

test-8-select test-8-select-pull: test-8-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/white_utf8.csv ${REDIRECT} ${TMP_DIR}/$@.out1
	@${CMP} ${TMP_DIR}/$@.out1 expected/test-8-select.out1 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --whitespace-clean ${TEST_DATA_DIR}/test/white_utf8.csv ${REDIRECT} ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/test-8-select.out2 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --whitespace-clean-no-newline ${TEST_DATA_DIR}/test/white_utf8.csv ${REDIRECT} ${TMP_DIR}/$@.out3
	@${CMP} ${TMP_DIR}/$@.out3 expected/test-8-select.out3 && ${TEST_PASS} || ${TEST_FAIL}

test-9-select test-9-select-pull: test-9-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/quoted3.csv -q ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-9-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-10-select: test-10-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< expected/test-2tsv-2.out -t --unescape ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-onlycrlf-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
ifneq ($(ZSV_NO_ONLY_CRLF),1)
	@${PREFIX} $< -O "|" --no-quote ../../data/test/crlf.txt > ${TMP_DIR}/$@.out1
	@${CMP} ${TMP_DIR}/$@.out1 expected/$@.out1 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< -O "|" --only-crlf --no-quote ../../data/test/crlf.txt > ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --only-crlf ../../data/test/crlf-2.csv > ${TMP_DIR}/$@.out3
	@${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL}
endif

test-10-select-pull test-search-select-pull test-regex-select-pull test-onlycrlf-select-pull:
	@echo 'N/a ($@)'

test-11-select test-11-select-pull: test-11-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@echo "A1,B1" | ${PREFIX} $< --header-row "column1,column2" > ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-11-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-12-select test-12-select-pull: test-12-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@${PREFIX} $< -n -x 1-2 -x 6 -x 8-10 -x 13- ${TEST_DATA_DIR}/test/desc.csv > ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-12-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-13-select test-13-select-pull: test-13-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< --header-row-span 2 ${TEST_DATA_DIR}/test/select-13.csv | head -1 > ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/test-13-select.out && ${TEST_PASS} || ${TEST_FAIL})

test-search-select: test-search-% : ${BUILD_DIR}/bin/zsv_%${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@${PREFIX} $< worldcitiespop_mil.csv --search escu > ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
	@echo "TO DO: select --search with overwritten values"

test-regex-select: test-regex-% : ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
ifeq ($(HAVE_PCRE2_8),1)
	@${PREFIX} $< worldcitiespop_mil.csv --regex-search '^esc[uo]' > ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
else
	@echo "  regex not available in this build"
endif

test-fixed-1-select-pull test-fixed-2-select-pull test-fixed-3-select-pull test-fixed-4-select-pull:
	@echo "$@: n/a"

test-fixed-1-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/fixed.csv --fixed 3,7,12,18,20,21,22 ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-fixed-1-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-fixed-2-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/fixed-auto.txt --fixed-auto ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-fixed-2-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-fixed-3-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/fixed-auto2.txt --fixed-auto ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-fixed-3-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-fixed-4-select: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/fixed-auto3.txt --fixed-auto ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/test-fixed-4-select.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< ${TEST_DATA_DIR}/fixed-auto4a.txt --fixed-auto ${REDIRECT} ${TMP_DIR}/$@a.out
	@${CMP} ${TMP_DIR}/$@a.out expected/test-fixed-4a-select.out && ${TEST_PASS} || ${TEST_FAIL}

test-rm: ${BUILD_DIR}/bin/zsv_prop${EXE} ${BUILD_DIR}/bin/zsv_rm${EXE}
	@${TEST_INIT}
	@echo 'hi' > ${TMP_DIR}/$@.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TMP_DIR}/$@.csv -R 1 -d 2 ${REDIRECT} /dev/null
	@find ${TMP_DIR}/.zsv/data/$@.csv/props.json -type f >/dev/null
	@${PREFIX} ${BUILD_DIR}/bin/zsv_rm${EXE} ${TMP_DIR}/$@.csv -f ${REDIRECT} /dev/null
	@find ${TMP_DIR}/.zsv/data/$@.csv/props.json -type f 2>/dev/null && ${TEST_FAIL} || ${TEST_PASS}
	@# -f forgives a missing file (exit 0); regression: unlink's -1 was once
	@# compared against ENOENT, making this exit 255
	@rm -f ${TMP_DIR}/$@-gone.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_rm${EXE} ${TMP_DIR}/$@-gone.csv -f ${REDIRECT} /dev/null && ${TEST_PASS} || ${TEST_FAIL}

test-mv: ${BUILD_DIR}/bin/zsv_prop${EXE} ${BUILD_DIR}/bin/zsv_mv${EXE}
	@${TEST_INIT}
	@echo 'hi' > ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/.zsv/data/$@.csv ${TMP_DIR}/.zsv/data/$@-moved.csv ${TMP_DIR}/$@-moved.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TMP_DIR}/$@.csv -R 1 -d 2 ${REDIRECT} /dev/null
	@find ${TMP_DIR}/.zsv/data/$@.csv/props.json -type f >/dev/null
	@${PREFIX} ${BUILD_DIR}/bin/zsv_mv${EXE} ${TMP_DIR}/$@.csv ${TMP_DIR}/$@-moved.csv ${REDIRECT} /dev/null
	@find ${TMP_DIR}/.zsv/data/$@-moved.csv/props.json -type f >/dev/null && ${TEST_PASS} || ${TEST_FAIL}

# non-ASCII names, one outside the Windows-1252 code page, reach zsv as UTF-8:
# on Windows this needs the manifest that sets the process code page to UTF-8
TEST_MV_UTF8_SRC=${TMP_DIR}/test-mv-utf8-é-日本.csv
TEST_MV_UTF8_DST=${TMP_DIR}/test-mv-utf8-moved-日本.csv
test-mv-utf8: ${BUILD_DIR}/bin/zsv_prop${EXE} ${BUILD_DIR}/bin/zsv_mv${EXE}
	@${TEST_INIT}
	@rm -rf "${TEST_MV_UTF8_SRC}" "${TEST_MV_UTF8_DST}" "${TMP_DIR}/.zsv/data/$(notdir ${TEST_MV_UTF8_SRC})" "${TMP_DIR}/.zsv/data/$(notdir ${TEST_MV_UTF8_DST})"
	@echo 'hi' > "${TEST_MV_UTF8_SRC}"
	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} "${TEST_MV_UTF8_SRC}" -R 1 -d 2 ${REDIRECT} /dev/null
	@test -f "${TMP_DIR}/.zsv/data/$(notdir ${TEST_MV_UTF8_SRC})/props.json"
	@${PREFIX} ${BUILD_DIR}/bin/zsv_mv${EXE} "${TEST_MV_UTF8_SRC}" "${TEST_MV_UTF8_DST}" ${REDIRECT} /dev/null
	@(test -f "${TEST_MV_UTF8_DST}" && ! test -e "${TEST_MV_UTF8_SRC}" && \
	  test -f "${TMP_DIR}/.zsv/data/$(notdir ${TEST_MV_UTF8_DST})/props.json") && ${TEST_PASS} || ${TEST_FAIL}

test-blank-leading-rows: test-blank-leading-rows-1 test-blank-leading-rows-2 test-blank-leading-rows-3 test-blank-leading-rows-4

test-blank-leading-rows-1: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/test/blank-leading-rows.csv ${REDIRECT} ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err
	@(${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${CMP} ${TMP_DIR}/$@.err expected/$@.err) && ${TEST_PASS} || ${TEST_FAIL}

test-blank-leading-rows-2: ${BUILD_DIR}/bin/zsv_serialize${EXE}
	@${TEST_INIT}
	@${PREFIX} $< -S ${TEST_DATA_DIR}/test/blank-leading-rows.csv ${REDIRECT} ${TMP_DIR}/$@.out 2>&1
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-blank-leading-rows-3: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< -R 1 ${TEST_DATA_DIR}/test/blank-leading-rows.csv ${REDIRECT} ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err
	@(${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${CMP} ${TMP_DIR}/$@.err expected/$@.err) && ${TEST_PASS} || ${TEST_FAIL}

test-blank-leading-rows-4: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} $< -R 2 ${TEST_DATA_DIR}/test/blank-leading-rows.csv ${REDIRECT} ${TMP_DIR}/$@.out 2>&1
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-stack: test-stack1 test-stack2 test-stack3 test-stack4 test-stack-filename

test-stack-filename: ${BUILD_DIR}/bin/zsv_stack${EXE}
	@${TEST_INIT}
	@cd ${TEST_DATA_DIR} && ${PREFIX} $< --prepend-filename stack[12].csv ${REDIRECT} $(abspath ${TMP_DIR})/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-stack1: ${BUILD_DIR}/bin/zsv_stack${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/stack[12].csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-stack2: ${BUILD_DIR}/bin/zsv_stack${EXE}
	@${TEST_INIT}
	@${PREFIX} $< ${TEST_DATA_DIR}/stack2-[12].csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-stack3: ${BUILD_DIR}/bin/zsv_stack${EXE}
	@${TEST_INIT}
	@echo 'a,b,c' > ${TMP_DIR}/1.csv
	@${PREFIX} $< ${TMP_DIR}/1.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-stack4: ${BUILD_DIR}/bin/zsv_stack${EXE}
	@${TEST_INIT}
	@echo 'a,b,c,a,b' > ${TMP_DIR}/$@-1.csv
	@echo '1,2,3,4,5' >> ${TMP_DIR}/$@-1.csv
	@echo 'a,b,c,a,b' > ${TMP_DIR}/$@-2.csv
	@echo 'z1,z2,z3,z4,z5' >> ${TMP_DIR}/$@-2.csv
	@${PREFIX} $< ${TMP_DIR}/$@-1.csv ${TMP_DIR}/$@-2.csv ${REDIRECT} ${TMP_DIR}/$@.out
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
	@${PREFIX} $< --unique ${TMP_DIR}/$@-1.csv ${TMP_DIR}/$@-2.csv ${REDIRECT} ${TMP_DIR}/$@-2.out
	@${CMP} ${TMP_DIR}/$@-2.out expected/$@-2.out && ${TEST_PASS} || ${TEST_FAIL}

test-sql test-flatten : test-%: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-flatten-2 : test-%-2: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< --row-id 'Loan Number' -- 'Current Loan Amount'=array 'Current Interest Rate=json' < "${TEST_DATA_DIR}/test/$*-2.csv" ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

# flatten is a producer: when the row-id column name also occurs as an output value
# it would otherwise emit a duplicate header (a,a,b). Ensure it disambiguates to
# a,a_2,b by default and notes the rename on stderr (renames are never silent).
test-flatten-dupcol: ${BUILD_DIR}/bin/zsv_flatten${EXE}
	@${TEST_INIT}
	@printf 'a,key,val\n1,a,x\n1,b,y\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv --row-id a --col-name key -V val > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out \
	  && grep -q 'renamed duplicate column "a"' ${TMP_DIR}/$@.err && ${TEST_PASS} || ${TEST_FAIL}

test-pretty: test-pretty-1 test-pretty-escape-chars

test-pretty-1 : test-%-1: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-pretty-escape-chars: ${BUILD_DIR}/bin/zsv_pretty${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/pretty-escape.csv -M ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

# .tsv/.tab input files default to a tab delimiter (== -t); other extensions and
# an explicit delimiter are unaffected. Exercises the app/utils/prop.c seam that
# every delimited-input command routes through. Fails before the seam was added.
test-tab-auto-recognize: ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@cp ${TEST_DATA_DIR}/Excel.tsv ${TMP_DIR}/$@.tsv && \
	cp ${TEST_DATA_DIR}/Excel.tsv ${TMP_DIR}/$@.tab && \
	cp ${TEST_DATA_DIR}/Excel.tsv ${TMP_DIR}/$@.csv && \
	${PREFIX} $< -t ${TMP_DIR}/$@.tsv > ${TMP_DIR}/$@-t.out && \
	${PREFIX} $< ${TMP_DIR}/$@.tsv > ${TMP_DIR}/$@-tsv.out && \
	${PREFIX} $< ${TMP_DIR}/$@.tab > ${TMP_DIR}/$@-tab.out && \
	${PREFIX} $< ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@-csv.out && \
	cmp ${TMP_DIR}/$@-tsv.out ${TMP_DIR}/$@-t.out && \
	cmp ${TMP_DIR}/$@-tab.out ${TMP_DIR}/$@-t.out && \
	! cmp -s ${TMP_DIR}/$@-csv.out ${TMP_DIR}/$@-t.out && \
	${TEST_PASS} || ${TEST_FAIL}

test-2tsv: test-2tsv-1 test-2tsv-2 test-2tsv-3

test-2tsv-1 test-2tsv-2: test-% : ${BUILD_DIR}/bin/zsv_2tsv${EXE}
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for 2tsv" && exit 1) || \
	(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-2tsv-3: ${BUILD_DIR}/bin/zsv_2tsv${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< --convert-to-space < ${TEST_DATA_DIR}/test/2tsv-2.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

${THIS_MAKEFILE_DIR}/../../data/quoted5.csv: ${THIS_MAKEFILE_DIR}/../../data/quoted5.csv.bz2
	@bzip2 -d -c $< > $@

test-serialize-quoted: ${BUILD_DIR}/bin/zsv_serialize${EXE} ${THIS_MAKEFILE_DIR}/../../data/quoted5.csv
	@${TEST_INIT}
	@rm -f ${TMP_DIR}/$@.out
	@(${PREFIX} $< ./../../data/quoted5.csv ${REDIRECT1} ${TMP_DIR}/$@.out1 && \
	grep xxxxxx < ${TMP_DIR}/$@.out1 | grep Produktinfo > ${TMP_DIR}/$@.out && rm ${TMP_DIR}/$@.out1 && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-serialize-additional: ${BUILD_DIR}/bin/zsv_serialize${EXE} ${TEST_DATA_DIR}/test/serialize.csv
	@${TEST_INIT}
	@(${PREFIX} $< < ${TEST_DATA_DIR}/test/serialize.csv -a 'Interest Paid Through Date' -a 'original INTEREST Only Term' ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-serialize-position-id: ${BUILD_DIR}/bin/zsv_serialize${EXE} ${TEST_DATA_DIR}/test/serialize.csv
	@${TEST_INIT}
	@(${PREFIX} $< --id-column 3 < ${TEST_DATA_DIR}/test/serialize.csv -a 'Interest Paid Through Date' -a 'original INTEREST Only Term' ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

test-serialize : test-%: ${BUILD_DIR}/bin/zsv_%${EXE} test-serialize-quoted test-serialize-additional test-serialize-position-id
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< -p < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@-2.out && \
	${CMP} ${TMP_DIR}/$@-2.out expected/$@-2.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< -p < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@-2.out && \
	${CMP} ${TMP_DIR}/$@-2.out expected/$@-2.out && ${TEST_PASS} || ${TEST_FAIL})

test-sql: test-sql2 test-sql3 test-sql4 test-sql5 ${STDIN_FILENAME_SQL_TESTS} test-sql-dupcol test-sql-dupcol-rename test-sql-dupcol-msg \
          test-sql-dupcol-default test-sql-dupcol-3x test-sql-dupcol-collide test-sql-dupcol-caseins \
          test-sql-dupcol-nodup
test-sql2: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@echo ${ARGS-sql} > ${TMP_DIR}/$@.sql
	@(${PREFIX} $< '@'${TMP_DIR}/$@.sql ${TEST_DATA_DIR}/test/sql.csv ${REDIRECT1} ${TMP_DIR}/$@.out) && \
	${CMP} ${TMP_DIR}/$@.out expected/test-sql.out && ${TEST_PASS} || ${TEST_FAIL}

test-sql3: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< --join-indexes 8 ${TEST_DATA_DIR}/test/sql.csv ${TEST_DATA_DIR}/test/sql.csv ${REDIRECT1} ${TMP_DIR}/$@.out) && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-sql4: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< ${TEST_DATA_DIR}/test/blank-leading-rows.csv -d 2 'select * from data' ${REDIRECT1} ${TMP_DIR}/$@.out)
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

test-sql5: ${BUILD_DIR}/bin/zsv_sql${EXE} # test blank rows
	@${TEST_INIT}
	@echo 'a,b,c' > ${TMP_DIR}/1.csv
	@(${PREFIX} $< ${TMP_DIR}/1.csv 'select * from data' ${REDIRECT1} ${TMP_DIR}/$@.out)
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}

# Regression for the duplicate-column-name abort under the opt-out flag: input whose
# header has the same name twice makes SQLite reject the CREATE TABLE. With
# --error-on-duplicate-columns, zsv sql must report the error and exit *gracefully*
# (normal nonzero code), not die by signal (SIGABRT -> 128+6=134), which is what the
# pre-fix double-free in zsv_sqlite3_add_csv() caused. We assert the exit code is a
# graceful failure (nonzero and < 128) rather than a signal death.
test-sql-dupcol: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,b,a\n1,2,3\n' > ${TMP_DIR}/$@.csv
	@${RC_PREFIX} $< --error-on-duplicate-columns ${TMP_DIR}/$@.csv 'select 1 from data limit 1' > ${TMP_DIR}/$@.out 2>&1; \
	  rc=$$?; \
	  printf '  exit=%s\n' "$$rc"; \
	  [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}

# Back-compat: --rename-duplicate-columns still makes repeated header names unique
# (a,b,a -> a,b,a_2). The renamed data goes to stdout; the rename is noted on stderr
# as a single summary line containing the token 'auto-renamed' (never silent). (F7 G7)
test-sql-dupcol-rename: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,b,a\n1,2,3\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< --rename-duplicate-columns ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err
	@n=$$(grep -c -i 'auto-renamed' ${TMP_DIR}/$@.err); ${CMP} ${TMP_DIR}/$@.out expected/$@.out \
	  && [ "$$n" = "1" ] && ${TEST_PASS} || ${TEST_FAIL}

# Opt-out: with --error-on-duplicate-columns a duplicate header is detected at parse
# time and reported with a single actionable message naming the column and its 1-based
# positions (rather than SQLite's generic "bad schema"), while exiting gracefully. (F7 G5)
test-sql-dupcol-msg: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,b,a\n1,2,3\n' > ${TMP_DIR}/$@.csv
	@${RC_PREFIX} $< --error-on-duplicate-columns ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2>&1; \
	  rc=$$?; \
	  printf '  exit=%s\n' "$$rc"; \
	  grep -q 'duplicate column name "a" at columns 1 and 3' ${TMP_DIR}/$@.out \
	    && [ "$$rc" -ne 0 ] && [ "$$rc" -lt 128 ] && ${TEST_PASS} || ${TEST_FAIL}

# F7 G1/G2: duplicate input columns are auto-disambiguated by DEFAULT (no flag) ->
# exit 0, header BV_NUM,name,amount,BV_NUM_2, and exactly ONE 'auto-renamed' stderr line.
test-sql-dupcol-default: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'BV_NUM,name,amount,BV_NUM\n1,a,10,100\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err; \
	  rc=$$?; h=$$(sed -n '1p' ${TMP_DIR}/$@.out | ${STRIP_CR}); n=$$(grep -c -i 'auto-renamed' ${TMP_DIR}/$@.err); \
	  [ "$$rc" = "0" ] && [ "$$h" = "BV_NUM,name,amount,BV_NUM_2" ] && [ "$$n" = "1" ] && ${TEST_PASS} || ${TEST_FAIL}

# F7 G3/G4: three 'a' columns -> a,a_2,a_3,b, and the two renames COLLAPSE to one line.
test-sql-dupcol-3x: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,a,a,b\n1,2,3,4\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err; \
	  rc=$$?; h=$$(sed -n '1p' ${TMP_DIR}/$@.out | ${STRIP_CR}); n=$$(grep -c -i 'auto-renamed' ${TMP_DIR}/$@.err); \
	  [ "$$rc" = "0" ] && [ "$$h" = "a,a_2,a_3,b" ] && [ "$$n" = "1" ] && ${TEST_PASS} || ${TEST_FAIL}

# F7 G8 + decision D1 (collision-stability): a,a,a_2 -> a,a_3,a_2. The synthesized name
# skips past the user's explicit a_2 (left intact); all three output headers are unique.
test-sql-dupcol-collide: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,a,a_2\n1,2,3\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err; \
	  rc=$$?; h=$$(sed -n '1p' ${TMP_DIR}/$@.out | ${STRIP_CR}); \
	  uq=$$(printf '%s' "$$h" | tr ',' '\n' | sort -u | grep -c .); \
	  [ "$$rc" = "0" ] && [ "$$h" = "a,a_3,a_2" ] && [ "$$uq" = "3" ] && ${TEST_PASS} || ${TEST_FAIL}

# F7 G9: case-insensitive duplicate (BV_NUM/bv_num) is auto-fixed -> exit 0, 2 columns.
test-sql-dupcol-caseins: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'BV_NUM,bv_num\n1,2\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err; \
	  rc=$$?; h=$$(sed -n '1p' ${TMP_DIR}/$@.out | ${STRIP_CR}); \
	  tot=$$(printf '%s' "$$h" | tr ',' '\n' | grep -c .); \
	  [ "$$rc" = "0" ] && [ "$$tot" = "2" ] && ${TEST_PASS} || ${TEST_FAIL}

# F7 G6 (guard, no false positive): a file with no duplicates exits 0 with NO summary line.
test-sql-dupcol-nodup: ${BUILD_DIR}/bin/zsv_sql${EXE}
	@${TEST_INIT}
	@printf 'a,b,c\n1,2,3\n' > ${TMP_DIR}/$@.csv
	@${PREFIX} $< ${TMP_DIR}/$@.csv 'select * from data' > ${TMP_DIR}/$@.out 2> ${TMP_DIR}/$@.err; \
	  rc=$$?; n=$$(grep -c -i 'auto-renamed' ${TMP_DIR}/$@.err); \
	  [ "$$rc" = "0" ] && [ "$$n" = "0" ] && ${TEST_PASS} || ${TEST_FAIL}

# The parent app/Makefile owns the binaries' dependencies, so every run asks it whether a
# binary is current: FORCE makes these rules recurse even when the binary exists, and an edit
# the parent tracks (a tool's .c file, a utils object's sources, or any file a unity build's
# -MMD depfile lists) rebuilds the binary before a test runs it. Other headers a non-unity tool
# includes, such as include/zsv.h, are untracked, and the parent does not rebuild an existing
# libzsv for a src/ edit. Make re-checks the binary's time after the recipe, so it remakes a
# target built from the binary (e.g. worldcitiespop_mil.tsv) only when the binary changed. Each
# binary's check runs its own parent make, so run the suite serially: under -j, two of them can
# rebuild the same object file at once
FORCE:

# QUIET=1 keeps the per-binary check to the parent's one config line. ${MAKE} stays literal in
# each recipe so make treats the line as recursive (runs it under -n, passes the jobserver)
PARENT_MAKE_ECHO=$(if $(filter 1,$(QUIET)),@)
PARENT_MAKE_FLAGS=$(if $(filter 1,$(QUIET)),-s --no-print-directory) -C .. $@ CONFIGFILE=${CONFIGFILEPATH} DEBUG=${DEBUG}

${BUILD_DIR}/bin/zsv_%${EXE}: FORCE
	${PARENT_MAKE_ECHO}${MAKE} ${PARENT_MAKE_FLAGS}

# The unified `cli` (zsv) multitool is built by the parent app/Makefile, not by the
# zsv_% pattern rule above. test-compare-redline-v2 exercises `cli help compare <topic>`
# routing, so the standalone suite must be able to build it on a clean tree (CI) the same
# way it builds the zsv_* binaries -- by delegating to the parent.
${BUILD_DIR}/bin/cli${EXE}: FORCE
	${PARENT_MAKE_ECHO}${MAKE} ${PARENT_MAKE_FLAGS}

test-2db: test-%: ${BUILD_DIR}/bin/zsv_%${EXE} worldcitiespop_mil.csv ${BUILD_DIR}/bin/zsv_2json${EXE} ${BUILD_DIR}/bin/zsv_select${EXE}
	@${TEST_INIT}
	@${PREFIX} ${BUILD_DIR}/bin/zsv_select${EXE} -H 24999 -N worldcitiespop_mil.csv > ${TMP_DIR}/$@.csv
	@${PREFIX} ${BUILD_DIR}/bin/zsv_2json${EXE} --database --index "country_ix on country" --unique-index "ux on [#]" ${TMP_DIR}/$@.csv > ${TMP_DIR}/$@.json
	@(${PREFIX} $< ${ARGS-$*} -o ${TMP_DIR}/$@.db --table data --overwrite < ${TMP_DIR}/test-2db.json ${REDIRECT1} ${TMP_DIR}/$@.out)
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
	@sqlite3 ${TMP_DIR}/$@.db .schema | sed 's/ IF NOT EXISTS//' | sed 's/"data"/data/g' > ${TMP_DIR}/$@.out2
	@${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL}
	@sqlite3 ${TMP_DIR}/$@.db "select count(*) from data" > ${TMP_DIR}/$@.out3
	@${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL}

# test-2db-csv: build a SQLite3 DB directly from CSV input (no JSON round-trip).
# Covers basic conversion, legacy-pipeline agreement, --table, extension
# detection / overrides, .tsv TAB, edge cases (header-only, ragged, empty &
# duplicate headers, quoted fields) and error cases.
DB2=${BUILD_DIR}/bin/zsv_2db${EXE}
test-2db-csv: ${BUILD_DIR}/bin/zsv_2db${EXE} ${BUILD_DIR}/bin/zsv_2json${EXE}
	@${TEST_INIT}
	@# --- fixtures ---
	@printf 'name,age,city\nAlice,30,NYC\nBob,25,LA\nCarol,40,SF\n' > ${TMP_DIR}/$@-basic.csv
	@# 1a. basic CSV -> DB: schema
	@(${PREFIX} ${DB2} ${TMP_DIR}/$@-basic.csv -o ${TMP_DIR}/$@-basic.db --overwrite ${REDIRECT1} ${TMP_DIR}/$@.run.out)
	@sqlite3 ${TMP_DIR}/$@-basic.db .schema | sed 's/ IF NOT EXISTS//' > ${TMP_DIR}/$@.schema.out
	@${CMP} ${TMP_DIR}/$@.schema.out expected/$@.schema.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 1b. row count
	@sqlite3 ${TMP_DIR}/$@-basic.db 'select count(*) from mytable' > ${TMP_DIR}/$@.count.out
	@${CMP} ${TMP_DIR}/$@.count.out expected/$@.count.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 1c. data
	@sqlite3 ${TMP_DIR}/$@-basic.db 'select * from mytable' > ${TMP_DIR}/$@.data.out
	@${CMP} ${TMP_DIR}/$@.data.out expected/$@.data.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 1d. agreement with the legacy 2json | 2db pipeline (same bytes in, same rows out)
	@${BUILD_DIR}/bin/zsv_2json${EXE} --database ${TMP_DIR}/$@-basic.csv | ${DB2} -o ${TMP_DIR}/$@-legacy.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-legacy.db 'select * from mytable' > ${TMP_DIR}/$@.legacy.out
	@${CMP} ${TMP_DIR}/$@.legacy.out ${TMP_DIR}/$@.data.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 2. --table honored for CSV input
	@${DB2} ${TMP_DIR}/$@-basic.csv -o ${TMP_DIR}/$@-tbl.db --table people --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-tbl.db 'select count(*) from people' > ${TMP_DIR}/$@.tbl.out
	@${CMP} ${TMP_DIR}/$@.tbl.out expected/$@.count.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 3a. extension override: --from-csv forces CSV on a .json-named file
	@cp ${TMP_DIR}/$@-basic.csv ${TMP_DIR}/$@-asjson.json
	@${DB2} --from-csv ${TMP_DIR}/$@-asjson.json -o ${TMP_DIR}/$@-csvjson.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-csvjson.db 'select count(*) from mytable' > ${TMP_DIR}/$@.csvjson.out
	@${CMP} ${TMP_DIR}/$@.csvjson.out expected/$@.count.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 3b. .tsv auto-detects CSV and sets TAB delimiter
	@printf 'a\tb\tc\n1\t2\t3\n' > ${TMP_DIR}/$@-x.tsv
	@${DB2} ${TMP_DIR}/$@-x.tsv -o ${TMP_DIR}/$@-tsv.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-tsv.db 'select * from mytable' > ${TMP_DIR}/$@.tsv.out
	@${CMP} ${TMP_DIR}/$@.tsv.out expected/$@.tsv.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 4a. header-only input -> empty table, exit 0
	@printf 'h1,h2,h3\n' > ${TMP_DIR}/$@-honly.csv
	@${DB2} ${TMP_DIR}/$@-honly.csv -o ${TMP_DIR}/$@-honly.db --overwrite >/dev/null 2>&1 && echo "rows=0 exit=$$?" > ${TMP_DIR}/$@.honly.out
	@printf 'rows=0 exit=0\n' | ${CMP} - ${TMP_DIR}/$@.honly.out && ${TEST_PASS} || ${TEST_FAIL}
	@sqlite3 ${TMP_DIR}/$@-honly.db 'select count(*) from mytable' > ${TMP_DIR}/$@.honlycount.out
	@printf '0\n' | ${CMP} - ${TMP_DIR}/$@.honlycount.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 4b. empty header cell -> synthesized column_<n>; duplicate headers -> de-duped
	@printf 'a,,c,c\n1,2,3,4\n' > ${TMP_DIR}/$@-edge.csv
	@${DB2} ${TMP_DIR}/$@-edge.csv -o ${TMP_DIR}/$@-edge.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-edge.db .schema | sed 's/ IF NOT EXISTS//' > ${TMP_DIR}/$@.edge.out
	@${CMP} ${TMP_DIR}/$@.edge.out expected/$@.edge.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 4c. ragged rows (short pads with empty, long truncates to header width)
	@printf 'a,b,c\n1,2\n3,4,5,6\n' > ${TMP_DIR}/$@-rag.csv
	@${DB2} ${TMP_DIR}/$@-rag.csv -o ${TMP_DIR}/$@-rag.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-rag.db 'select * from mytable' > ${TMP_DIR}/$@.rag.out
	@${CMP} ${TMP_DIR}/$@.rag.out expected/$@.rag.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 4d. quoted fields with embedded comma and newline (round-trip via the parser)
	@printf 'a,b\n"x,y","l1\nl2"\n' > ${TMP_DIR}/$@-q.csv
	@${DB2} ${TMP_DIR}/$@-q.csv -o ${TMP_DIR}/$@-q.db --overwrite >/dev/null 2>&1
	@sqlite3 ${TMP_DIR}/$@-q.db "select a || '|' || b from mytable" > ${TMP_DIR}/$@.q.out
	@printf 'x,y|l1\nl2\n' | ${CMP} - ${TMP_DIR}/$@.q.out && ${TEST_PASS} || ${TEST_FAIL}
	@# 5. error cases (each must exit non-zero)
	@(! ${DB2} ${TMP_DIR}/$@-basic.csv >/dev/null 2>&1) && ${TEST_PASS} || ${TEST_FAIL}
	@(! ${DB2} ${TMP_DIR}/$@-basic.csv -o ${TMP_DIR}/$@-basic.db >/dev/null 2>&1) && ${TEST_PASS} || ${TEST_FAIL}
	@(! ${DB2} --from-csv --from-json ${TMP_DIR}/$@-basic.csv -o ${TMP_DIR}/$@-z.db --overwrite >/dev/null 2>&1) && ${TEST_PASS} || ${TEST_FAIL}

test-jq: test-%: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@(${PREFIX} $< keys ./../../docs/db.schema.json ${REDIRECT1} ${TMP_DIR}/$@.out)
	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL}
	@(${PREFIX} $< 'keys|.[]' ./../../docs/db.schema.json --raw-output ${REDIRECT1} ${TMP_DIR}/$@.raw.out)
	@${CMP} ${TMP_DIR}/$@.raw.out expected/$@.raw.out && ${TEST_PASS} || ${TEST_FAIL}

test-2json: test-%: ${BUILD_DIR}/bin/zsv_%${EXE} ${BUILD_DIR}/bin/zsv_2db${EXE} ${BUILD_DIR}/bin/zsv_select${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

#	ajv validate --strict-tuples=false -s ${THIS_MAKEFILE_DIR}/../../docs/csv.schema.json -d expected/$@.out.json [suffix must be json]
	@(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/$*.csv --compact ${REDIRECT1} ${TMP_DIR}/$@.compact.out && \
	${CMP} ${TMP_DIR}/$@.compact.out expected/$@.compact.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --object < ${TEST_DATA_DIR}/quoted2.csv ${REDIRECT1} ${TMP_DIR}/$@.out2 && \
	${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --no-header < ${TEST_DATA_DIR}/quoted2.csv ${REDIRECT1} ${TMP_DIR}/$@.out3 && \
	${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --database < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out4 && \
	${CMP} ${TMP_DIR}/$@.out4 expected/$@.out4 && ${TEST_PASS} || ${TEST_FAIL})

#	ajv validate --strict-tuples=false -s ${THIS_MAKEFILE_DIR}/../../docs/db.schema.json -d expected/$@.out4.json [suffix must be json]

	@(${PREFIX} $< --object --no-empty < ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out5 && \
	${CMP} ${TMP_DIR}/$@.out5 expected/$@.out5 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --object --no-empty < ${TEST_DATA_DIR}/quoted4.csv ${REDIRECT1} ${TMP_DIR}/$@.out6 && \
	${CMP} ${TMP_DIR}/$@.out6 expected/$@.out6 && ${TEST_PASS} || ${TEST_FAIL})

	@${PREFIX} ${BUILD_DIR}/bin/zsv_select${EXE} -H 1999 -N worldcitiespop_mil.csv | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2json${EXE} --database --index "country_ix on country" --unique-index "ux on [#]" | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2db${EXE} -o ${TMP_DIR}/$@.db --table data --overwrite
	@(${PREFIX} $< --from-db ${TMP_DIR}/$@.db ${REDIRECT1} ${TMP_DIR}/$@.out7 && \
	${CMP} ${TMP_DIR}/$@.out7 expected/$@.out7 && ${TEST_PASS} || ${TEST_FAIL})
#	ajv validate --strict-tuples=false -s ${THIS_MAKEFILE_DIR}/../../docs/db.schema.json -d expected/$@.out7.json [suffix must be json]

ifneq ($(ZSV_NO_TOON),1)
	@# --from-toon: convert a TOON document to JSON
	@(printf '[2]{a,b}:\n  1,x\n  2,"y,z"\n' | ${PREFIX} $< --from-toon ${REDIRECT1} ${TMP_DIR}/$@.from-toon && \
	${CMP} ${TMP_DIR}/$@.from-toon expected/$@.from-toon && ${TEST_PASS} || ${TEST_FAIL})

	@# --from-toon is mutually exclusive with --from-db
	@(printf '[0]:\n' | $< --from-toon --from-db /dev/null >/dev/null 2>&1 && ${TEST_FAIL} || ${TEST_PASS})
endif

# 2toon mirrors 2json but emits TOON. Same inputs/options; the db input for
# --from-db is built the same way (2json | 2db) and read back with 2toon.
test-2toon: test-%: ${BUILD_DIR}/bin/zsv_%${EXE} ${BUILD_DIR}/bin/zsv_2db${EXE} ${BUILD_DIR}/bin/zsv_2json${EXE} ${BUILD_DIR}/bin/zsv_select${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/2json.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/2json.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< ${ARGS-$*} < ${TEST_DATA_DIR}/test/2json.csv --compact ${REDIRECT1} ${TMP_DIR}/$@.compact.out && \
	${CMP} ${TMP_DIR}/$@.compact.out expected/$@.compact.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --object < ${TEST_DATA_DIR}/quoted2.csv ${REDIRECT1} ${TMP_DIR}/$@.out2 && \
	${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --no-header < ${TEST_DATA_DIR}/quoted2.csv ${REDIRECT1} ${TMP_DIR}/$@.out3 && \
	${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --database < ${TEST_DATA_DIR}/test/2json.csv ${REDIRECT1} ${TMP_DIR}/$@.out4 && \
	${CMP} ${TMP_DIR}/$@.out4 expected/$@.out4 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --object --no-empty < ${TEST_DATA_DIR}/test/2json.csv ${REDIRECT1} ${TMP_DIR}/$@.out5 && \
	${CMP} ${TMP_DIR}/$@.out5 expected/$@.out5 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --object --no-empty < ${TEST_DATA_DIR}/quoted4.csv ${REDIRECT1} ${TMP_DIR}/$@.out6 && \
	${CMP} ${TMP_DIR}/$@.out6 expected/$@.out6 && ${TEST_PASS} || ${TEST_FAIL})

	@${PREFIX} ${BUILD_DIR}/bin/zsv_select${EXE} -H 1999 -N worldcitiespop_mil.csv | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2json${EXE} --database --index "country_ix on country" --unique-index "ux on [#]" | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2db${EXE} -o ${TMP_DIR}/$@.db --table data --overwrite
	@(${PREFIX} $< --from-db ${TMP_DIR}/$@.db ${REDIRECT1} ${TMP_DIR}/$@.out7 && \
	${CMP} ${TMP_DIR}/$@.out7 expected/$@.out7 && ${TEST_PASS} || ${TEST_FAIL})

	@# --from-json: convert a JSON document to TOON
	@(printf '[{"a":1,"b":"x"},{"a":2,"b":"y,z"}]' | ${PREFIX} $< --from-json ${REDIRECT1} ${TMP_DIR}/$@.from-json && \
	${CMP} ${TMP_DIR}/$@.from-json expected/$@.from-json && ${TEST_PASS} || ${TEST_FAIL})

	@# round-trip JSON -> TOON (2toon --from-json) -> JSON (2json --from-toon) is the identity
	@(printf '[{"a":1,"b":"x"},{"a":2,"b":"y,z"}]' | ${PREFIX} $< --from-json | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2json${EXE} --from-toon ${REDIRECT1} ${TMP_DIR}/$@.roundtrip && \
	${CMP} ${TMP_DIR}/$@.roundtrip expected/$@.roundtrip && ${TEST_PASS} || ${TEST_FAIL})

test-2json-help test-2toon-help: test-%-help: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@[ "`${PREFIX} $< -h | wc -l`" -gt 15 ] && ${TEST_PASS} || ${TEST_FAIL} # check output nonempty
	@${PREFIX} $< -h >/dev/null && ${TEST_PASS} || ${TEST_FAIL} # check exit code

test-desc: test-%: ${BUILD_DIR}/bin/zsv_%${EXE}
	@${TEST_INIT}
	@( ( ! [ -s "${TEST_DATA_DIR}/test/$*.csv" ] ) && echo "No test input for $*") || \
	(${PREFIX} $< -q ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})
	@(${PREFIX} $< ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out2 && \
	${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL})
	@(${PREFIX} $< -H ${TEST_DATA_DIR}/test/$*.csv ${REDIRECT1} ${TMP_DIR}/$@.out3 && \
	${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL})
	@(${PREFIX} $< ${TEST_DATA_DIR}/test/$*-trim.csv ${REDIRECT1} ${TMP_DIR}/$@.trim && \
	${CMP} ${TMP_DIR}/$@.trim expected/$@.trim && ${TEST_PASS} || ${TEST_FAIL})

test-compare-tolerance: ${BUILD_DIR}/bin/zsv_compare${EXE}
	@(${PREFIX} $< ../../data/compare/tolerance1.csv ../../data/compare/tolerance2.csv ${REDIRECT1} ${TMP_DIR}/$@.out1 && \
	${CMP} ${TMP_DIR}/$@.out1 expected/$@.out1 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --tolerance 0.001 ../../data/compare/tolerance1.csv ../../data/compare/tolerance2.csv ${REDIRECT1} ${TMP_DIR}/$@.out2 && \
	${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --tolerance 0.0001 ../../data/compare/tolerance1.csv ../../data/compare/tolerance2.csv ${REDIRECT1} ${TMP_DIR}/$@.out3 && \
	${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< --tolerance 0.00001 ../../data/compare/tolerance1.csv ../../data/compare/tolerance2.csv ${REDIRECT1} ${TMP_DIR}/$@.out4 && \
	${CMP} ${TMP_DIR}/$@.out4 expected/$@.out4 && ${TEST_PASS} || ${TEST_FAIL})

test-compare: test-%: ${BUILD_DIR}/bin/zsv_%${EXE} ${COMPARE_TOON_DEP}
	@${TEST_INIT}
	@(${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv ${REDIRECT1} ${TMP_DIR}/$@.out && \
	${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t4.csv ${REDIRECT1} ${TMP_DIR}/$@.out2 && \
	${CMP} ${TMP_DIR}/$@.out2 expected/$@.out2 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< -k C compare/t1.csv compare/t5.csv compare/t6.csv ${REDIRECT1} ${TMP_DIR}/$@.out3 && \
	${CMP} ${TMP_DIR}/$@.out3 expected/$@.out3 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< -k C --sort compare/t1.csv compare/t5.csv compare/t6-unsorted.csv ${REDIRECT1} ${TMP_DIR}/$@.out4 && \
	${CMP} ${TMP_DIR}/$@.out4 expected/$@.out4 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< -k C compare/t1.csv compare/t5.csv compare/t6-unsorted.csv ${REDIRECT1} ${TMP_DIR}/$@.out5 && \
	${CMP} ${TMP_DIR}/$@.out5 expected/$@.out5 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --json ${REDIRECT1} ${TMP_DIR}/$@.out6 && \
	${CMP} ${TMP_DIR}/$@.out6 expected/$@.out6 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --json-object ${REDIRECT1} ${TMP_DIR}/$@.out7 && \
	${CMP} ${TMP_DIR}/$@.out7 expected/$@.out7 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t7.csv compare/t3.csv --json-object -k c ${REDIRECT1} ${TMP_DIR}/$@.out8 && \
	${CMP} ${TMP_DIR}/$@.out8 expected/$@.out8 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t7.csv compare/t3.csv --print-key-colname -k c ${REDIRECT1} ${TMP_DIR}/$@.out9 && \
	${CMP} ${TMP_DIR}/$@.out9 expected/$@.out9 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< compare/t1.csv compare/t7.csv compare/t3.csv --print-key-colname -k c ${REDIRECT1} ${TMP_DIR}/$@.out9 && \
	${CMP} ${TMP_DIR}/$@.out9 expected/$@.out9 && ${TEST_PASS} || ${TEST_FAIL})

	@(${PREFIX} $< ../../data/compare/t1.csv ../../data/compare/t2.csv --add AccentCity --sort -k country -k city ${REDIRECT1} ${TMP_DIR}/$@.out10 && \
	${CMP} ${TMP_DIR}/$@.out10 expected/$@.out10 && ${TEST_PASS} || ${TEST_FAIL})

	@# --require-all-inputs: ignore rows whose key is missing from any input (inner join);
	@# keys 3 (only in a) and 4 (only in b) are dropped, the key-2 value diff is retained
	@(${PREFIX} $< -k id --require-all-inputs compare/reqall_a.csv compare/reqall_b.csv ${REDIRECT1} ${TMP_DIR}/$@.out11 && \
	${CMP} ${TMP_DIR}/$@.out11 expected/$@.out11 && ${TEST_PASS} || ${TEST_FAIL})

	@# --intersect is an alias for --require-all-inputs (byte-identical output)
	@(${PREFIX} $< -k id --intersect compare/reqall_a.csv compare/reqall_b.csv ${REDIRECT1} ${TMP_DIR}/$@.out11b && \
	${CMP} ${TMP_DIR}/$@.out11b expected/$@.out11 && ${TEST_PASS} || ${TEST_FAIL})

	@# --require-all-inputs in JSON mode
	@(${PREFIX} $< -k id --require-all-inputs --json compare/reqall_a.csv compare/reqall_b.csv ${REDIRECT1} ${TMP_DIR}/$@.out12 && \
	${CMP} ${TMP_DIR}/$@.out12 expected/$@.out12 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: explicit ranges
	@(${PREFIX} $< --columns 1-3v4-6 compare/colrange1.csv ${REDIRECT1} ${TMP_DIR}/$@-colrange.out1 && \
	${CMP} ${TMP_DIR}/$@-colrange.out1 expected/$@-colrange.out1 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: shorthand single-column spec
	@(${PREFIX} $< --columns 1v4 compare/colrange1.csv ${REDIRECT1} ${TMP_DIR}/$@-colrange.out2 && \
	${CMP} ${TMP_DIR}/$@-colrange.out2 expected/$@-colrange.out2 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: column name lookup
	@(${PREFIX} $< --columns "Old_A vs New_A" compare/colrange2.csv ${REDIRECT1} ${TMP_DIR}/$@-colrange.out3 && \
	${CMP} ${TMP_DIR}/$@-colrange.out3 expected/$@-colrange.out3 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: duplicate column names (second match used for right side)
	@(${PREFIX} $< --columns "ABC vs ABC" compare/colrange3.csv ${REDIRECT1} ${TMP_DIR}/$@-colrange.out4 && \
	${CMP} ${TMP_DIR}/$@-colrange.out4 expected/$@-colrange.out4 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: single column name with duplicate (equivalent to "ABC vs ABC")
	@(${PREFIX} $< --columns "ABC" compare/colrange3.csv ${REDIRECT1} ${TMP_DIR}/$@-colrange.out5 && \
	${CMP} ${TMP_DIR}/$@-colrange.out5 expected/$@-colrange.out5 && ${TEST_PASS} || ${TEST_FAIL})

	@# --columns: error - single column with no duplicate
	@($< --columns "B" compare/colrange3.csv >/dev/null 2>&1 && ${TEST_FAIL} || ${TEST_PASS})

	@# --columns: error - requires exactly one file
	@($< --columns 1v4 compare/colrange1.csv compare/colrange1.csv >/dev/null 2>&1 && ${TEST_FAIL} || ${TEST_PASS})

	@# --columns: error - invalid spec
	@($< --columns bad compare/colrange1.csv >/dev/null 2>&1 && ${TEST_FAIL} || ${TEST_PASS})

ifneq ($(ZSV_NO_TOON),1)
	@# --toon: TOON output (same comparison as --json-object out7, rendered as TOON)
	@(${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --toon ${REDIRECT1} ${TMP_DIR}/$@.toon && \
	${CMP} ${TMP_DIR}/$@.toon expected/$@.toon && ${TEST_PASS} || ${TEST_FAIL})

	@# --toon is equivalent to --json-object piped through `2toon --from-json`
	@(${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --json-object | \
	${PREFIX} ${BUILD_DIR}/bin/zsv_2toon${EXE} --from-json ${REDIRECT1} ${TMP_DIR}/$@.toon-equiv && \
	${CMP} ${TMP_DIR}/$@.toon-equiv expected/$@.toon && ${TEST_PASS} || ${TEST_FAIL})

	@# get_default_output_format() does NOT affect the (non-redline) comparison output: with
	@# AI_AGENT set and no format flag, output stays the default CSV (byte-identical to out).
	@# The AI_AGENT default only selects TOON under --redline (see test-compare-redline-v2).
	@(AI_AGENT=1 ${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv ${REDIRECT1} ${TMP_DIR}/$@.ai-default && \
	${CMP} ${TMP_DIR}/$@.ai-default expected/$@.out && ${TEST_PASS} || ${TEST_FAIL})

	@# an explicit --toon still yields TOON even when AI_AGENT is unset (blank env)
	@(AI_AGENT= ${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --toon ${REDIRECT1} ${TMP_DIR}/$@.toon-noenv && \
	${CMP} ${TMP_DIR}/$@.toon-noenv expected/$@.toon && ${TEST_PASS} || ${TEST_FAIL})

	@# an explicit --json yields JSON regardless of AI_AGENT (byte-identical to plain --json)
	@(AI_AGENT=1 ${PREFIX} $< compare/t1.csv compare/t2.csv compare/t3.csv --json ${REDIRECT1} ${TMP_DIR}/$@.json-override && \
	${CMP} ${TMP_DIR}/$@.json-override expected/$@.out6 && ${TEST_PASS} || ${TEST_FAIL})

	@# --json and --toon together is an error
	@($< compare/t1.csv compare/t2.csv compare/t3.csv --json --toon >/dev/null 2>&1 && ${TEST_FAIL} || ${TEST_PASS})
endif

include test-compare-redline.mk

test-sheet: test-%: ${BUILD_DIR}/bin/zsv_%${EXE} worldcitiespop_mil.csv test-sheet-cleanup test-sheet-all

test-sheet-cleanup:
	@rm -f tmux-*.log

# The '/'-prefixed regex syntax needs pcre2; without it a spec is reported as
# unsupported, so gate these the same way test-sheet-filter-this-regex is gated
ifeq ($(HAVE_PCRE2_8),1)
  SHEET_REGEX_TESTS=test-sheet-regex-find test-sheet-regex-delim test-sheet-regex-find-help test-sheet-regex-filter \
	test-sheet-caseless-regex
else
  SHEET_REGEX_TESTS=
endif

# --only-crlf is compiled out by configure --disable-only-crlf
ifneq ($(ZSV_NO_ONLY_CRLF),1)
  SHEET_ONLY_CRLF_TESTS=test-sheet-edit-only-crlf
else
  SHEET_ONLY_CRLF_TESTS=
endif

test-sheet-all: \
	test-sheet-1 test-sheet-2 test-sheet-3 test-sheet-4 test-sheet-5 test-sheet-6 test-sheet-7 test-sheet-8 test-sheet-9 \
	test-sheet-10 test-sheet-11 test-sheet-12 test-sheet-13 test-sheet-14 test-sheet-15 test-sheet-16 test-sheet-17 \
	test-sheet-18 test-sheet-19 test-sheet-20 \
	test-sheet-esc-last test-sheet-find-after-filter \
	test-sheet-subcommand \
	test-sheet-caseless-find test-sheet-caseless-filter test-sheet-caseless-find-file \
	test-sheet-prop-cmd-opt \
	test-sheet-pivot-1 \
	test-sheet-pivot-V \
	test-sheet-pivot-rowid \
	test-sheet-sqlfilter-1 \
	test-sheet-sqlfilter-dupcol \
	test-sheet-sqlfilter-rowid \
	test-sheet-sort-1 \
	test-sheet-sort-2 \
	test-sheet-sort-3 \
	test-sheet-sort-4 \
	test-sheet-sort-expr-invalid \
	test-sheet-sort-blank \
	test-sheet-sort-rownum \
	test-sheet-sort-err \
	test-sheet-sort-bang \
	test-sheet-sort-f \
	test-sheet-sort-n \
	test-sheet-sort-ambig \
	test-sheet-sort-rowidshadow \
	test-sheet-sort-tripleshadow \
	test-sheet-sort-chain \
	test-sheet-errors-1 \
	test-sheet-filter-this \
	test-sheet-quit \
	test-sheet-quit-alias \
	test-sheet-tab-complete \
	test-sheet-pivot-exact \
	test-sheet-find-blank-rows \
	test-sheet-reload-blank-rows \
	test-sheet-edit-save \
	test-sheet-edit-inplace-big \
	test-sheet-edit-filter \
	test-sheet-edit-find \
	test-sheet-edit-refuse \
	test-sheet-edit-read-only \
	test-sheet-edit-refuse-opts \
	test-sheet-edit-symlink \
	test-sheet-edit-utf8 \
	test-sheet-edit-recommit \
	test-sheet-edit-hardlink \
	test-sheet-edit-mode \
	test-sheet-edit-mode-saveas \
	test-sheet-edit-mode-long \
	test-sheet-overlay \
	test-sheet-overlay-more \
	test-sheet-edit-mode-no-boundary \
	test-sheet-edit-mode-no-boundary-clear \
	test-sheet-edit-cut-refused \
	test-sheet-footer-long \
	test-sheet-edit-mode-clipboard \
	test-sheet-quit-confirm \
	test-sheet-ctrl-c \
	test-sheet-copy-no-tool \
	test-sheet-copy-too-long \
	test-sheet-status-timeout \
	test-sheet-suspend \
	test-sheet-quit-signal \
	test-sheet-quit-signal-ignored \
	${SHEET_ONLY_CRLF_TESTS} \
	test-sheet-edit-help \
	${SHEET_REGEX_TESTS}
	@rm -f tmux-*.log zSE*
	@(for SESSION in $^; do ! tmux -L "$$SESSION" kill-server 2>/dev/null || true; done && ${TEST_PASS} || ${TEST_FAIL})

TMUX_TERM=xterm-256color
test-sheet-1: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ -v new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ Down Up && \
	${EXPECT} $@ && ${TEST_PASS} || (for x in `ls tmux-*.log 2>/dev/null`; do echo "Log $$x:" && cat $$x; done && ${TEST_FAIL}))

test-sheet-2: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "C-d" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-3: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "f" "sarmaj" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-4: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "C-d" "C-d" "C-u" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-5: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 160 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-6: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 50 -d -s $@ "${PREFIX} $< -d 3 ${TEST_DATA_DIR}/test/mixed-line-endings.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" "C-u" "C-u" "C-u" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-7: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< -d 3 ${TEST_DATA_DIR}/test/mixed-line-endings.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" "g" "g" "C-u" "/" "1234" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-8: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 160 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=10 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "f" "e" ENTER && \
	${EXPECT} $@ filtered && \
	tmux -L $@ send-keys -t $@ "G" "C-u" "k" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-9: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< -d 3 ${TEST_DATA_DIR}/test/mixed-line-endings.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ bottom && \
	tmux -L $@ send-keys -t $@ -N 4096 "k" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# The filter reports "(N filtered rows) " from its on_done callback, and the worker commits the
# last index row after that, when it deletes the writer. <G> goes to the last committed row, so
# a <G> sent on the message alone can stop one row short: repeat it until the last row shows
test-sheet-10: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.tsv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< -t worldcitiespop_mil.tsv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "f" "e" ENTER && \
	${EXPECT} $@ filtered && \
	for i in $$(seq 100); do tmux -L $@ send-keys -t $@ "G"; \
	  tmux -L $@ capture-pane -p -t $@ | grep -q '^999999 ' && break; sleep 0.05; done && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-11: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 50 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ -N 11 "C-d" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-12: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "f" "el" ENTER && \
	${EXPECT} $@ filtered-el && \
	tmux -L $@ send-keys -t $@ "f" "al" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-13: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 40 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-14: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 40 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ ESCAPE && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# <esc> never closes the last buffer the user opened (that would leave the blank
# base view); it still closes a transient view such as help
test-sheet-esc-last: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 40 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	tmux -L $@ send-keys -t $@ ESCAPE && \
	${EXPECT} $@ esc && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ ESCAPE ESCAPE && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# find in a filtered buffer: its data file already carries "Row #", so the found
# column must not be shifted right by one. The footer shows the cursor cell, so
# each stage asserts the column: bar (C, not the last column so an off-by-one
# cannot be masked by clamping), then n -> baz (B, not bar again), then qux (C).
# Finally, from the top, /2 must NOT match the "Row #" cell of row 2 (no data
# cell contains a digit), so the cursor stays on bar rather than jumping to 2
test-sheet-find-after-filter: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B,C,D\nq,foo,bar,x\nw,baz,qux,y\ne,zzz,yyy,z\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "f" "ba" ENTER && \
	${EXPECT} $@ filtered && \
	tmux -L $@ send-keys -t $@ "/" "ba" ENTER && \
	${EXPECT} $@ bar && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ baz && \
	tmux -L $@ send-keys -t $@ "/" "qux" ENTER && \
	${EXPECT} $@ qux && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" "2" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-15: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< -d 3 ${TEST_DATA_DIR}/test/crlf-line-ending.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ bottom && \
	tmux -L $@ send-keys -t $@ -N 4096 "k" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-16: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< ${TEST_DATA_DIR}/test/header-only.csv" && \
	${EXPECT} $@ indexed && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-17: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 50 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "500G" && \
	${EXPECT} $@ 500g && \
	tmux -L $@ send-keys -t $@ "0G" && \
	${EXPECT} $@ 0g && \
	tmux -L $@ send-keys -t $@ "1G" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Find column ("|") then find-next-column ("\") cycles through every header that
# matches the search term. "it" matches City, AccentCity and Latitude; the footer
# shows the selected cell value, confirming the cursor lands on each in turn.
test-sheet-18: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "|" "it" ENTER && \
	${EXPECT} $@ find-col && \
	tmux -L $@ send-keys -t $@ "\\" && \
	${EXPECT} $@ next-1 && \
	tmux -L $@ send-keys -t $@ "\\" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# find works in the help screen just like in a regular sheet
# The last stage parks the cursor on a Description cell: the footer must show the whole
# 60-char find description after the 19-char help status, i.e. within 80 columns
test-sheet-19: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ "/" "find" ENTER && \
	${EXPECT} $@ found && \
	tmux -L $@ send-keys -t $@ "l" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# filter works in the help screen (in-memory buffer materialized to a temp CSV)
test-sheet-20: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ "f" "Filter" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-subcommand: \
	test-sheet-subcommand-open-file-prompt test-sheet-subcommand-open-file-argument \
	test-sheet-subcommand-filter-file-prompt test-sheet-subcommand-filter-file-argument

test-sheet-subcommand-open-file-prompt: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $<" && \
	${EXPECT} $@ blank && \
	tmux -L $@ send-keys -t $@ ":open" ENTER && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "worldcitiespop_mil.csv" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-subcommand-open-file-argument: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $<" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ blank && \
	tmux -L $@ send-keys -t $@ ":open worldcitiespop_mil.csv" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-subcommand-filter-file-prompt: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ blank && \
	tmux -L $@ send-keys -t $@ ":filter" ENTER && \
	${EXPECT} $@ filter && \
	tmux -L $@ send-keys -t $@ "ireland" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-subcommand-filter-file-argument: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ blank && \
	tmux -L $@ send-keys -t $@ ":filter \"ireland\"" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-prop-cmd-opt: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${BUILD_DIR}/bin/zsv_prop${EXE}
	@echo "TO DO: test-sheet-prop-cmd-opt"
#	@${TEST_INIT}
#	@${PREFIX} ${BUILD_DIR}/bin/zsv_prop${EXE} ${TEST_DATA_DIR}/echo-prop.csv --header-row-span 2 > /dev/null
#	@tmux kill-session 2>/dev/null >/dev/null || printf ''
#	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
#	@tmux -v new-session -x 80 -y 5 -d -s $@
#	@sleep 0.5
#	@tmux send-keys -t $@ "${PREFIX} $< ${TEST_DATA_DIR}/echo-prop.csv" ENTER
#	@sleep 0.1
#	@tmux send-keys -t $@ "f" "A" ENTER
#	@sleep 0.5
#	@tmux -v capture-pane -t $@ -p ${REDIRECT1} ${TMP_DIR}/$@.out
#	@tmux send-keys -t $@ "q"
#	@${CMP} ${TMP_DIR}/$@.out expected/$@.out && ${TEST_PASS} || (echo 'Incorrect output:' && cat ${TMP_DIR}/$@.out && ${TEST_FAIL})

test-sheet-pivot: test-sheet-pivot-1 test-sheet-pivot-V test-sheet-pivot-rowid

test-sheet-pivot-1: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ l v && \
	${EXPECT} $@ groups && \
	tmux -L $@ send-keys -t $@ j j Enter && \
	${EXPECT} $@ drilldown && \
	tmux -L $@ send-keys -t $@ G && \
	${EXPECT} $@ \
	&& ${TEST_PASS} || ${TEST_FAIL})

test-sheet-pivot-V: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "V" "Country||'hi'" ENTER && \
	${EXPECT} $@ groups && \
	tmux -L $@ send-keys -t $@ j j j j j j j j j j j j j j j j j j j j j j j ENTER && \
	${EXPECT} $@ drilldown && \
	${TEST_PASS} || ${TEST_FAIL})

test-sheet-sqlfilter: test-sheet-sqlfilter-1 test-sheet-sqlfilter-dupcol test-sheet-sqlfilter-rowid

# `where` on a file whose data column is literally named rowid: the synthesized
# Row # must be positional (1,2,3), not the shadowing column's values (7,3,5) --
# an unshadowed rowid spelling is chosen by zsvsheet_sql_rowid_ref()
test-sheet-sqlfilter-rowid: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'rowid,v\n7,a\n3,b\n5,c\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":where" ENTER "v like '%'" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# pivot drill-down on the same shadowed file: Row # positional likewise
test-sheet-pivot-rowid: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'rowid,v\n7,a\n3,b\n5,c\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "l" "v" && \
	${EXPECT} $@ groups && \
	tmux -L $@ send-keys -t $@ ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Exercises the capability sqlfilter's dedupe-on enables: a file whose header
# repeats a column name can still be filtered in sheet. The initial view shows the
# raw duplicate header (Country, City, Country); after :where, sqlfilter builds the
# underlying table -- disambiguating the repeat to Country_2 -- and returns the
# filtered rows. Pre-change this filter could not build its table at all.
test-sheet-sqlfilter-dupcol: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'Country,City,Country\nus,new york,USA\nfr,paris,France\nde,berlin,Germany\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":where" ENTER "City like 'b%'" ENTER && \
	${EXPECT} $@ filter && \
	${TEST_PASS} || ${TEST_FAIL})

test-sheet-sqlfilter-1: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ ":where" ENTER "city like 'ber%'" ENTER && \
	${EXPECT} $@ filter && \
	tmux -L $@ send-keys -t $@ ":where" ENTER "this is invalid" ENTER && \
	${EXPECT} $@ invalid && \
	tmux -L $@ send-keys -t $@ ":where" ENTER "City = -1" ENTER && \
	${EXPECT} $@ noresults && \
	${TEST_PASS} || ${TEST_FAIL})

test-sheet-sort: test-sheet-sort-1 test-sheet-sort-2 test-sheet-sort-3 test-sheet-sort-4 \
	test-sheet-sort-expr-invalid test-sheet-sort-blank test-sheet-sort-rownum test-sheet-sort-err \
	test-sheet-sort-bang test-sheet-sort-f test-sheet-sort-n test-sheet-sort-ambig \
	test-sheet-sort-rowidshadow test-sheet-sort-tripleshadow test-sheet-sort-chain

# :sort! is an alias for sortdesc; the in-recipe cmp asserts its screen is
# byte-identical to the sortdesc-by-key fixture (test-sheet-sort-2.out)
test-sheet-sort-bang: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ ":sort! Country" ENTER && \
	${EXPECT} $@ && \
	cmp expected/$@.out expected/test-sheet-sort-2.out && ${TEST_PASS} || ${TEST_FAIL})

# :sort <col> f -- float semantics: cast-only, no grouping; cells without a
# leading decimal float cast to 0.0 and sort inline at 0.0, ties (the 0.0
# group) in original order via the ROWID tiebreak
test-sheet-sort-f: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'name,val\na,-5\nb,.5\nc,+2\nd,3.7\ne,10\nf,1e3\ng,apple\nh,zebra\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":sort val f" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# :sort n with the cursor on a column (no column named n exists): leading-
# integer semantics -- no-leading-number cells first in original order, then
# by leading integer value ('12abc' reads 12, '3.7' reads 3, '1e3' reads 1;
# 'item12' and '.5' have no leading number)
test-sheet-sort-n: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'name,val\na,12abc\nb,3.7\nc,1e3\nd,-5\ne,+2\nf,item12\ng,.5\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "l" ":sort n" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# flag-vs-column ambiguity: with a real column named n, ':sort n' sorts that
# column (leftmost flag-shaped token that resolves takes the column slot);
# ':sort v n' sorts column v with integer semantics (the doc-taught form: a
# flag-shaped token must NOT steal a slot already taken by an earlier column
# token, even one not yet resolved); ':sort n f' sorts column n with float
# semantics
test-sheet-sort-ambig: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'n,v\n5,x\nzz,y\n.5,z\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":sort n" ENTER && \
	${EXPECT} $@ coln && \
	tmux -L $@ send-keys -t $@ ESCAPE ":sort v n" ENTER && \
	${EXPECT} $@ vn && \
	tmux -L $@ send-keys -t $@ ESCAPE ":sort n f" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# regression test for the rowid-shadow fix: with a data column literally named
# `rowid`, sort's Row # projection and stability tiebreak use an unshadowed
# rowid spelling, so both stay positional -- within the v='b' tie the rows
# keep original order (Row # 1 then 2) and Row # shows positions (3,1,2), not
# the shadowing column's values
test-sheet-sort-rowidshadow: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'rowid,v\n9,b\n2,b\n7,a\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":sort v" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# fallback branch of zsvsheet_sql_rowid_ref(): with columns rowid AND _rowid_
# AND oid, every spelling is shadowed and the helper falls back to "ROWID", so
# the tiebreak binds to the data column: within the v='b' tie the rows order
# 2-before-9 (by that column), NOT the positional/stable 9-before-2, and Row #
# shows the column's values. This is also the suite's discriminating test for
# the `, <rid> asc` tiebreak clause itself (see SELF-CERT T7): drop the clause
# and the b-tie reverts to stable original order
test-sheet-sort-tripleshadow: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'rowid,_rowid_,oid,v\n9,9,9,b\n2,2,2,b\n7,7,7,a\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":sort v" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# chained sorts are true secondary sorts: sort by a, then by b -- within the
# b='x' tie the a-order (2 before 3) is preserved. This PINS the behavior but
# does not isolate the rowid tiebreak clause: the sorter is observed-stable in
# every reachable configuration, so the same output appears without it (see
# SELF-CERT for the recorded skip); the tiebreak's presence is instead
# discriminated by test-sheet-sort-tripleshadow's shadowed-column tie order
test-sheet-sort-chain: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'a,b\n3,x\n1,y\n2,x\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":sort a" ENTER && \
	${EXPECT} $@ sorta && \
	tmux -L $@ send-keys -t $@ ":sort b" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a data column named `row #` (not detected as the row-number column: the match
# is exact, so lowercase differs) must not capture a default-position sort: 'O'
# on the synthesized Row # orders by ROWID, not by the same-named data column
test-sheet-sort-rownum: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'row #,v\n7,a\n3,b\n5,c\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "O" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# 'l' moves the cursor to Country; 'o' sorts ascending by it. The Row # column
# shows original row numbers, confirming rows were reordered, not renumbered.
test-sheet-sort-1: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "l" "o" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# same column, 'O': descending
test-sheet-sort-2: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "l" "O" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# 'O' with the cursor at the default position (column 0, Row #) yields descending
# row-number order (ascending would just reproduce the unsorted screen and assert
# nothing); :sort "Row #" desc must behave identically (same key resolution)
test-sheet-sort-3: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "O" && \
	${EXPECT} $@ keydesc && \
	tmux -L $@ send-keys -t $@ ESCAPE ':sort "Row #" desc' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# :sort <col> desc params path; the name is matched case-insensitively and the
# status bar shows the canonical column name (Country, not COUNTRY)
test-sheet-sort-4: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ ":sort COUNTRY desc" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# invalid SQL via :sortexpr: error in the status bar, buffer unchanged
test-sheet-sort-expr-invalid: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ ":sortexpr" ENTER "this is invalid" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# numeric-aware ordering and blanks-last in both directions, on a column mixing
# numbers, scientific notation, negatives, an empty cell and a whitespace-only
# cell; a text value in a numeric column ('pear') grouping after the numbers;
# a text sort where '1,234' (numeric prefix) and 'ee' (numeric charset,
# no digit) must sort as text; a valid :sortexpr with arguments (params-join +
# direction split); a sort of an already-sorted buffer (has_row_num path) by
# its Row # column, restoring the original order; and finally a sort of that
# buffer by a data column (has_row_num with cursor ordinal > 0)
test-sheet-sort-blank: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'name,val\nbanana,10\nApple,\nzebra,-3\ncherry,9.5\ndate, \nfig,1e2\n"1,234",4\nee,7\ngrape,pear\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "l" "o" && \
	${EXPECT} $@ asc && \
	tmux -L $@ send-keys -t $@ ESCAPE "l" "l" "O" && \
	${EXPECT} $@ desc && \
	tmux -L $@ send-keys -t $@ ESCAPE "^" "l" "o" && \
	${EXPECT} $@ name-asc && \
	tmux -L $@ send-keys -t $@ ESCAPE ":sortexpr length(name) desc" ENTER && \
	${EXPECT} $@ expr && \
	tmux -L $@ send-keys -t $@ "o" && \
	${EXPECT} $@ rownum && \
	tmux -L $@ send-keys -t $@ "l" "l" "O" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# error paths: unknown column; a second bare token when the column slot is
# already taken (both ':sort Country up' and ':sort Country City' -- these
# usage errors now occur AFTER db creation, so they exercise the goto-cleanup
# ladder and are part of the leaks run); too many params; empty after-strip
# expression; a trailing `desc` not preceded by whitespace (must NOT be split
# off: the expression is passed through and rejected as SQL); and sort on a
# buffer with no backing data file (the help screen -- scrolled to its end
# first, which also asserts the sortdesc/sortexpr help rows that fall below
# the 40-row capture of test-sheet-14)
test-sheet-sort-err: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ ":sort nosuchcol" ENTER && \
	${EXPECT} $@ badcol && \
	tmux -L $@ send-keys -t $@ ":sort Country up" ENTER && \
	${EXPECT} $@ baddir && \
	tmux -L $@ send-keys -t $@ ":sortdesc Country up" ENTER && \
	${EXPECT} $@ baddir2 && \
	tmux -L $@ send-keys -t $@ ":sort Country asc extra" ENTER && \
	${EXPECT} $@ toomany && \
	tmux -L $@ send-keys -t $@ ":sort Country City" ENTER && \
	${EXPECT} $@ twocol && \
	tmux -L $@ send-keys -t $@ ":sort n f n asc" ENTER && \
	${EXPECT} $@ fourparam && \
	tmux -L $@ send-keys -t $@ ":sortexpr asc" ENTER && \
	${EXPECT} $@ usage && \
	tmux -L $@ send-keys -t $@ ":sortexpr Latitude+0desc" ENTER && \
	${EXPECT} $@ nosplit && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ helpend && \
	tmux -L $@ send-keys -t $@ "o" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-errors: test-sheet-errors-1

${TMP_DIR}/test-sheet-errors-1.txt: worldcitiespop_mil.csv
	@head -1000 worldcitiespop_mil.csv | tr '\n' '|' > $@.tmp
	@echo 'x' >> $@.tmp
	@echo 'x' >> $@.tmp
	@echo 'x' >> $@.tmp
	@mv $@.tmp $@

test-sheet-errors-1: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TMP_DIR}/test-sheet-errors-1.txt
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< ${TMP_DIR}/test-sheet-errors-1.txt" && \
	${EXPECT} $@ indexed-errors && \
	tmux -L $@ send-keys -t $@ ":errors" ENTER && \
	${EXPECT} $@ errors && \
	tmux -L $@ send-keys -t $@ ESCAPE && \
	${EXPECT} $@ indexed-errors && \
	tmux -L $@ send-keys -t $@ ":errors-clear" ENTER && \
	${EXPECT} $@ indexed && \
	${TEST_PASS} || ${TEST_FAIL})

ifeq ($(HAVE_PCRE2_8),1)
test-sheet-filter-this: test-sheet-filter-this-1 test-sheet-filter-this-regex test-sheet-filter-this-regex-blank
else
test-sheet-filter-this: test-sheet-filter-this-1
endif

test-sheet-filter-this-1: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "l" "F" "casas" ENTER && \
	${EXPECT} $@ nodata && \
	tmux -L $@ send-keys -t $@ ESCAPE "l" "F" "mx" ENTER && \
	${EXPECT} $@ filter && \
	tmux -L $@ send-keys -t $@ ESCAPE ":filtercol" ENTER "mx" ENTER && \
	${EXPECT} $@ filter && \
	${TEST_PASS} || ${TEST_FAIL})

test-sheet-filter-this-regex: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 25 -d -s "$@" "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "l" "F" "/casas" ENTER && \
	${EXPECT} $@ nodata && \
	tmux -L $@ send-keys -t $@ ESCAPE "l" "F" "/^mx" ENTER && \
	${EXPECT} $@ filterregex && \
	tmux -L $@ send-keys -t $@ ESCAPE ":filtercol" ENTER "/^mx" ENTER && \
	${EXPECT} $@ filterregex && \
	${TEST_PASS} || ${TEST_FAIL})

# A blank cell in the filtered column must fail a regex that can't match an empty
# string (e.g. /[a-zA-Z]/), not be auto-included: h0 mixes non-blank alpha, blank,
# and non-blank non-alpha values so all three outcomes are exercised at once.
test-sheet-filter-this-regex-blank: ${BUILD_DIR}/bin/zsv_sheet${EXE}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'h0,h1\na,1\n,2\nb,3\n,4\n1,5\nc,6\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 12 -d -s "$@" "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "F" "/[a-zA-Z]/" ENTER && \
	${EXPECT} $@ filtered && \
	${TEST_PASS} || ${TEST_FAIL})

WAIT_FOR_EXIT=../../scripts/test-wait-for-exit.sh
TYPE_COMMAND=../../scripts/test-type-command.sh


# After building a pivot table, sheet moves the cursor to the group matching the
# cell it was on, matching the whole cell rather than a substring. 'us' is a strict
# substring of the earlier group 'aus', so a substring match would stop on 'aus'.
test-sheet-pivot-exact: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'Country,City\naus,sydney\nus,boston\naus,perth\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "j" && \
	${EXPECT} $@ on-us && \
	tmux -L $@ send-keys -t $@ "v" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Fixture for the regex find/filter tests. Its first column is deliberately NOT
# named "Row #", so sheet injects its own row-number column; that offset is what
# makes find-next resume at the cell after the cursor instead of re-matching it.
SHEET_REGEX_CSV=printf 'Row,Value\n1,xabc\n2,abc\n3,ABC\n4,/abc\n'

# 'q' no longer quits -- only the :quit command does. Sending "q" "G" together
# proves it: if 'q' still quit, there would be no session left to process the G.
# Ctrl-Space is checked the same way: getch() reports it as 0, which is also the
# "no key" marker on every command-only binding, quit's included. Note this stage is
# only a strong check while quit is the first ch==0 entry in the vim table -- it
# detects Ctrl-Space firing *quit*, not Ctrl-Space firing some other keyless command.
test-sheet-quit: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "q" "G" && \
	${EXPECT} $@ after-q && \
	tmux -L $@ send-keys -t $@ C-Space "g" "g" && \
	${EXPECT} $@ after-ctrl-space && \
	tmux -L $@ send-keys -t $@ ":quit" ENTER && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# ':q' is an alias for ':quit'
test-sheet-quit-alias: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":q" ENTER && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Tab cycles alphabetically through the command names matching what has been typed.
# The expected order assumes no extension has registered a 'p...' command.
test-sheet-tab-complete: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":" "p" Tab && \
	${EXPECT} $@ pagedown && \
	tmux -L $@ send-keys -t $@ Tab && \
	${EXPECT} $@ pageup && \
	tmux -L $@ send-keys -t $@ ESCAPE && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# find: '/'-prefixed values are regexes; '\/' escapes a leading literal slash.
# Each stage is chosen so the literal reading gives a different answer: '^abc'
# skips xabc, '/^abc$/i' can only reach ABC because of the flag, and '\/abc'
# reaches a cell no regex stage did.
# The last three stages check that a pattern that fails to compile is reported and
# then *ignored*: the previous pattern must survive, so the following 'n' advances
# from xabc to abc rather than doing nothing.
test-sheet-regex-find: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "/" '/^abc' ENTER && \
	${EXPECT} $@ anchored && \
	tmux -L $@ send-keys -t $@ "/" '/^abc$$/i' ENTER && \
	${EXPECT} $@ caseless && \
	tmux -L $@ send-keys -t $@ "/" '\/abc' ENTER && \
	${EXPECT} $@ literal-slash && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" 'abc' ENTER && \
	${EXPECT} $@ caseless-1 && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ caseless-2 && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ caseless-3 && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" '/abc' ENTER && \
	${EXPECT} $@ unanchored && \
	tmux -L $@ send-keys -t $@ "/" '/[' ENTER "n" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# The closing '/' is only a delimiter when every byte after it is a flag letter.
# In '/x/y' it is not, so the pattern is 'x/y' and the cursor lands on row 2; were
# the trailing bytes not checked it would be 'x' and land on row 1.
# '/a\/i' is the converse: the slash IS followed by a flag letter but is escaped,
# so the pattern is 'a\/i' and matches 'a/i'. Ignoring the escape would truncate it
# to the uncompilable 'a\' and the cursor would not move at all.
test-sheet-regex-delim: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'Row,Value\n1,x\n2,x/y\n3,a/i\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "/" '/x/y' ENTER && \
	${EXPECT} $@ unterminated && \
	tmux -L $@ send-keys -t $@ "/" '/a\/i' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# find over a static (in-memory) buffer takes a different code path than find over
# a file, so exercise the regex there too
test-sheet-regex-find-help: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "?" && \
	${EXPECT} $@ help && \
	tmux -L $@ send-keys -t $@ "/" '/^next$$' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# filter takes the same syntax as find. The last stage rejects an empty regex: were
# it compiled it would match every cell, so the view would become a 4-row filtered
# buffer instead of staying on the unfiltered one. The trailing G orders the check
# after the filter attempt rather than racing it.
test-sheet-regex-filter: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_REGEX_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "f" '/^abc$$/i' ENTER && \
	${EXPECT} $@ caseless && \
	tmux -L $@ send-keys -t $@ ESCAPE "f" '\/abc' ENTER && \
	${EXPECT} $@ literal && \
	tmux -L $@ send-keys -t $@ ESCAPE "f" "/" ENTER "G" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Literal find/filter ignore case (Unicode simple case mapping); the prompt accepts
# UTF-8 and Backspace erases a whole code point. Rows 4-6 hold the only non-ASCII
# code points whose lowercase is ASCII (U+0130, U+212A), which the ASCII fast path
# must still match. `tmux -u` sends the keys as UTF-8 regardless of the host locale.
SHEET_CASE_CSV=printf 'Row,Value\n1,D\303\266mitz\n2,D\303\226MITZ\n3,d\303\266mitz\n4,\304\260stanbul\n5,istanbul\n6,\342\204\252elvin\n'

test-sheet-caseless-find: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_CASE_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -u -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "/" 'dömitzö' BSpace ENTER && \
	${EXPECT} $@ 1 && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ 2 && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ 3 && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" 'İSTANBUL' ENTER && \
	${EXPECT} $@ 4 && \
	tmux -L $@ send-keys -t $@ "n" && \
	${EXPECT} $@ 5 && \
	tmux -L $@ send-keys -t $@ "/" 'kelvin' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Regexes stay case-sensitive, non-ASCII included: /DÖMITZ skips row 1 and lands on row 2;
# /dömitz/i then finds row 3
test-sheet-caseless-regex: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_CASE_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -u -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "/" '/DÖMITZ' ENTER && \
	${EXPECT} $@ exact && \
	tmux -L $@ send-keys -t $@ "/" '/dömitz/i' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

test-sheet-caseless-filter: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@${SHEET_CASE_CSV} > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -u -L $@ new-session -x 80 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "f" 'DÖMITZ' ENTER && \
	${EXPECT} $@ all && \
	tmux -L $@ send-keys -t $@ ESCAPE "l" "l" "F" 'KELVIN' ENTER && \
	${EXPECT} $@ col && \
	tmux -L $@ send-keys -t $@ ESCAPE ":filter" ENTER 'istanbul' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# The same through the data-file path (index + row-span pre-filter), not the static buffer
test-sheet-caseless-find-file: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -u -L $@ new-session -x 80 -y 6 -d -s $@ "${PREFIX} $< worldcitiespop_mil.csv" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "/" 'DÖNENÇAY' ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# A blank row is data, not an "empty header row". find seeks via the row index and
# resumes parsing mid-file, where zsv would otherwise drop leading blank rows -- which
# used to make every reported row number short by the number dropped, landing the cursor
# that many rows above the match. Two blank rows here, so the pre-fix offset is two.
test-sheet-find-blank-rows: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(printf 'h0,h1,h2\n,,\n,,\n'; printf 'r%s,s%s,t%s\n' 1 1 1; printf 'x,y,166UNIQ\n'; \
	  for i in 2 3 4 5 6 7 8 9 10; do printf 'r%s,s%s,t%s\n' $$i $$i $$i; done) > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 100 -y 10 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "/" "166UNIQ" ENTER && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# Reloading the buffer mid-file must not lose blank rows, and must not leave the
# previous occupant's cells behind. Needs a file larger than the 1024-row screen
# buffer so that G then gg actually forces a reload through the row index.
test-sheet-reload-blank-rows: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "id,val"; print ","; print ","; for(i=3;i<=4000;i++) printf "%d,R%d\n", i, i}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 60 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ bottom && \
	tmux -L $@ send-keys -t $@ "g" "g" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# edit cells (key and command), rename a header, refuse :q while modified, save in place
test-sheet-edit-save: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B,C\n1,x,y\n2,"a,b",z\n3,p,q\n' > ${TMP_DIR}/$@.csv && chmod 640 ${TMP_DIR}/$@.csv
	@rm -f ${TMP_DIR}/$@-copy.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" Enter && \
	${EXPECT} $@ unchanged && \
	tmux -L $@ send-keys -t $@ "i" C-a "X" Enter && \
	${EXPECT} $@ edited && \
	tmux -L $@ send-keys -t $@ "j" "j" ':cell "new, \"v\""' Enter && \
	tmux -L $@ send-keys -t $@ "j" "l" "i" C-a Enter && \
	tmux -L $@ send-keys -t $@ "g" "g" "k" "i" C-a "HDR" Enter && \
	tmux -L $@ send-keys -t $@ "i" Escape && \
	${EXPECT} $@ header && \
	tmux -L $@ send-keys -t $@ ":w $@-copy.csv" Enter ":q" Enter && \
	${EXPECT} $@ unsaved && \
	tmux -L $@ send-keys -t $@ Escape ":w" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	cmp ${TMP_DIR}/$@.csv expected/$@.csv && cmp ${TMP_DIR}/$@-copy.csv expected/$@.csv && \
	[ -n "$$(find ${TMP_DIR}/$@.csv -perm 640)" ] && ${TEST_PASS} || ${TEST_FAIL})

# edits on the index path (last row, row 500), in-place save, then navigate the rewritten file
test-sheet-edit-inplace-big: ${BUILD_DIR}/bin/zsv_sheet${EXE} worldcitiespop_mil.csv ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@cp worldcitiespop_mil.csv ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	export EXPECT_TIMEOUT=20 && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" "l" "i" C-a "LAST" Enter && \
	tmux -L $@ send-keys -t $@ "500G" "l" "l" "i" C-a "ROW500" Enter "G" && \
	${EXPECT} $@ reloaded && \
	tmux -L $@ send-keys -t $@ "500G" ":w" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ "g" "g" "G" && \
	${EXPECT} $@ bottom && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	sed -e '501s/,Qiu Quldi,/,ROW500,/' -e '1000001s/^ru,/LAST,/' worldcitiespop_mil.csv | cmp - ${TMP_DIR}/$@.csv && \
	${TEST_PASS} || ${TEST_FAIL})

# a modified view refuses the first <esc> and discards on the second; a saved one closes.
# A view saved as an existing file is named as typed; a file saved as new gets the
# permissions the umask gives a new file
test-sheet-edit-filter: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,foo\n2,bar\n3,food\n' > ${TMP_DIR}/$@.csv
	@printf 'old\n' > ${TMP_DIR}/$@-out.csv && rm -f ${TMP_DIR}/$@-other.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 8 -d -s $@ "cd ${TMP_DIR} && umask 027 && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "f" "foo" Enter && \
	${EXPECT} $@ filtered && \
	tmux -L $@ send-keys -t $@ "l" "l" "i" C-a "EDITED" Enter Escape && \
	${EXPECT} $@ esc-refused && \
	tmux -L $@ send-keys -t $@ "j" "i" C-a "E2" Enter && \
	${EXPECT} $@ edited-again && \
	tmux -L $@ send-keys -t $@ Escape && \
	${EXPECT} $@ esc-refused-again && \
	tmux -L $@ send-keys -t $@ Escape && \
	${EXPECT} $@ discarded && \
	tmux -L $@ send-keys -t $@ "f" "foo" Enter && \
	${EXPECT} $@ filtered && \
	tmux -L $@ send-keys -t $@ "i" && \
	${EXPECT} $@ rownum-refused && \
	tmux -L $@ send-keys -t $@ "l" "l" "i" C-a "EDITED" Enter ":w $@.csv" Enter && \
	${EXPECT} $@ open-elsewhere && \
	tmux -L $@ send-keys -t $@ ":w" Enter "$@-out.csv" Enter && \
	${EXPECT} $@ saved-as && \
	tmux -L $@ send-keys -t $@ "j" "i" C-a "FOOD2" Enter ":w $@-other.csv" Enter && \
	${EXPECT} $@ saved-elsewhere && \
	tmux -L $@ send-keys -t $@ ":w" Enter && \
	${EXPECT} $@ saved-again && \
	tmux -L $@ send-keys -t $@ Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	cmp ${TMP_DIR}/$@-out.csv expected/$@-out.csv && cmp ${TMP_DIR}/$@-other.csv expected/$@-out.csv && \
	[ -n "$$(find ${TMP_DIR}/$@-other.csv -perm 640)" ] && \
	printf 'A,B\n1,foo\n2,bar\n3,food\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# find reads the file, so it must see an unsaved edit
test-sheet-edit-find: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,R%d\n", i, i}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 60 -y 8 -d -s $@ "${PREFIX} $< ${TMP_DIR}/$@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "2500G" "l" "l" "i" C-a "needle" Enter "g" "g" "/" "needle" Enter && \
	${EXPECT} $@ found && \
	tmux -L $@ send-keys -t $@ ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# saves that would lose data are refused; a tab-delimited file saves to CSV with the
# quoting CSV needs
test-sheet-edit-refuse: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A\tB\n1\tx,y\n' > ${TMP_DIR}/$@.tsv
	@rm -f ${TMP_DIR}/$@-out.csv ${TMP_DIR}/$@-wide-out.csv
	@awk 'BEGIN{for(r=0;r<2;r++){for(i=0;i<1100;i++) printf "%s%s", (i?",":""), (r?i:"c" i); print ""}}' > ${TMP_DIR}/$@-wide.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.tsv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":w" Enter && \
	${EXPECT} $@ tsv-in-place && \
	tmux -L $@ send-keys -t $@ ":w $@-out.csv" Enter && \
	${EXPECT} $@ saved-csv && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" "x,y" Enter && \
	${EXPECT} $@ found-after-commit && \
	tmux -L $@ send-keys -t $@ "e" "$@-wide.csv" Enter "l" "i" C-a "W" Enter ":w $@-wide-out.csv" Enter && \
	${EXPECT} $@ too-wide && \
	tmux -L $@ send-keys -t $@ Escape Escape && \
	${EXPECT} $@ reloaded-after-commit && \
	tmux -L $@ send-keys -t $@ ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A\tB\n1\tx,y\n' | cmp - ${TMP_DIR}/$@.tsv && \
	printf 'A,B\nQ,"x,y"\n' | cmp - ${TMP_DIR}/$@-out.csv && \
	[ ! -e ${TMP_DIR}/$@-wide-out.csv ] && ${TEST_PASS} || ${TEST_FAIL})

# a save to a file without write permission is refused and leaves the file as it was. The
# superuser can write any file, so the test is skipped when run as root
test-sheet-edit-read-only: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@if [ "$$(id -u)" = 0 ]; then echo "$@: skipped: root can write a read-only file"; exit 0; fi; \
	echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf; \
	printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv; \
	rm -f ${TMP_DIR}/$@-ro.csv; printf 'A,B\n1,x\n' > ${TMP_DIR}/$@-ro.csv && chmod 444 ${TMP_DIR}/$@-ro.csv; \
	(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":w $@-ro.csv" Enter && \
	${EXPECT} $@ refused && \
	tmux -L $@ send-keys -t $@ ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B\n1,x\n' | cmp - ${TMP_DIR}/$@-ro.csv && ${TEST_PASS} || ${TEST_FAIL})

# parser options that skip rows make an in-place rewrite lossy, so it is refused
test-sheet-edit-refuse-opts: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'junk\nA,B\n1,x\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< -R 1 $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":w" Enter && \
	${EXPECT} $@ refused && \
	tmux -L $@ send-keys -t $@ "g" "g" "/" "x" Enter ":w" Enter && \
	${EXPECT} $@ refused-after-commit && \
	tmux -L $@ send-keys -t $@ "f" "x" Enter && \
	${EXPECT} $@ filtered-after-commit && \
	tmux -L $@ send-keys -t $@ ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'junk\nA,B\n1,x\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# saving through a symbolic link replaces the file it names, and a link to nothing or a
# directory is refused; a leading blank line is not a reason to refuse (the parser only
# notes that it skipped it)
test-sheet-edit-symlink: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf '\nA,B\n1,x\n' > ${TMP_DIR}/$@-real.csv && rm -f ${TMP_DIR}/$@.csv && ln -s $@-real.csv ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/$@-dangling.csv ${TMP_DIR}/$@-missing.csv ${TMP_DIR}/$@-dir ${TMP_DIR}/$@-alias.tsv && ln -s $@-missing.csv ${TMP_DIR}/$@-dangling.csv && \
	  mkdir ${TMP_DIR}/$@-dir && ln -s $@-real.csv ${TMP_DIR}/$@-alias.tsv
	@printf 'A\tB\n' > ${TMP_DIR}/$@-real.tsv && rm -f ${TMP_DIR}/$@-to-tsv.csv && ln -s $@-real.tsv ${TMP_DIR}/$@-to-tsv.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":w" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ ":w $@-dangling.csv" Enter && \
	${EXPECT} $@ dangling && \
	tmux -L $@ send-keys -t $@ ":w $@-dir" Enter && \
	${EXPECT} $@ directory && \
	tmux -L $@ send-keys -t $@ ":w $@-alias.tsv" Enter && \
	${EXPECT} $@ tsv-name && \
	tmux -L $@ send-keys -t $@ ":w $@-to-tsv.csv" Enter && \
	${EXPECT} $@ tsv-file && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	[ -L ${TMP_DIR}/$@.csv ] && [ ! -e ${TMP_DIR}/$@-missing.csv ] && printf 'A\tB\n' | cmp - ${TMP_DIR}/$@-real.tsv && printf 'A,B\nQ,x\n' | cmp - ${TMP_DIR}/$@-real.csv && ${TEST_PASS} || ${TEST_FAIL})

# the prompt starts with the current value, multibyte characters included
test-sheet-edit-utf8: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A\nD\303\244tt\346\227\245\346\234\254\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 60 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" BSpace && \
	${EXPECT} $@ prompt && \
	tmux -L $@ send-keys -t $@ Enter ":w" Enter ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A\nD\303\244tt\346\227\245\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# CRLF-only row ends would be lost by a rewrite (saving writes LF), so it is refused
test-sheet-edit-only-crlf: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\r\n1,x\r\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< --only-crlf $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":w" Enter && \
	${EXPECT} $@ refused && \
	tmux -L $@ send-keys -t $@ ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B\r\n1,x\r\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# edits committed twice (each find writes them to a new data file), a further edit, then
# :w over the opened file: the view keeps reading its data file and still shows every edit
test-sheet-edit-recommit: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,R%d\n", i, i}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 60 -y 8 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "10G" "l" "l" "i" C-a "E1" Enter "g" "g" "/" "E1" Enter && \
	tmux -L $@ send-keys -t $@ "2000G" "i" C-a "E2" Enter "g" "g" "/" "E2" Enter && \
	${EXPECT} $@ second-commit && \
	tmux -L $@ send-keys -t $@ "20G" "i" C-a "E3" Enter ":w" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ "G" "18G" && \
	${EXPECT} $@ after-save && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,%s\n", i, (i==10 ? "E1" : (i==2000 ? "E2" : (i==20 ? "E3" : "R" i)))}' | \
	cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# saving to another name (a hard link) of the opened file: that name gets the new file,
# and the view, still reading the old one, keeps its edits and stays modified
test-sheet-edit-hardlink: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,R%d\n", i, i}' > ${TMP_DIR}/$@.csv
	@rm -f ${TMP_DIR}/$@-link.csv && ln ${TMP_DIR}/$@.csv ${TMP_DIR}/$@-link.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 132 -y 8 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "l" "i" C-a "LONGER-VALUE" Enter ":w $@-link.csv" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ bottom && \
	tmux -L $@ send-keys -t $@ "g" "g" ":q" Enter && \
	${EXPECT} $@ still-modified && \
	tmux -L $@ send-keys -t $@ Escape ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,R%d\n", i, i}' | cmp - ${TMP_DIR}/$@.csv && \
	awk 'BEGIN{print "id,val"; for(i=1;i<=3000;i++) printf "%d,%s\n", i, (i==1 ? "LONGER-VALUE" : "R" i)}' | \
	  cmp - ${TMP_DIR}/$@-link.csv && ${TEST_PASS} || ${TEST_FAIL})

# edit mode: printable keys (digits too) type into the cell under the cursor; Enter (keypad
# Enter too), Tab, Shift-Tab and arrows store and move; F2 edits the current value; Delete
# clears; <esc> cancels typing, and otherwise leaves the mode; <ctrl>s saves. The status
# names the key that saves in the current mode
test-sheet-edit-mode: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B,C\n1,x,y\n2,"a,b",z\n3,p,q\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 8 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter && \
	${EXPECT} $@ on && \
	tmux -L $@ send-keys -t $@ "/" && \
	${EXPECT} $@ rownum && \
	tmux -L $@ send-keys -t $@ Right "1" "2" Enter Tab "x" "y" "z" Enter && \
	${EXPECT} $@ typed && \
	tmux -L $@ send-keys -t $@ Up F2 BSpace "Z" Enter && \
	${EXPECT} $@ f2 && \
	tmux -L $@ send-keys -t $@ "k" Up DC && \
	${EXPECT} $@ cleared && \
	tmux -L $@ send-keys -t $@ Down "m" && \
	${EXPECT} $@ typing && \
	tmux -L $@ send-keys -t $@ Escape && \
	${EXPECT} $@ cancelled && \
	tmux -L $@ send-keys -t $@ BTab "7" Up && \
	${EXPECT} $@ shift-tab && \
	tmux -L $@ send-keys -t $@ "q" Right "r" Left Right BSpace Left "s" Down "é" Up "t" BTab && \
	${EXPECT} $@ typed-nav && \
	tmux -L $@ send-keys -t $@ C-s && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ Escape && \
	${EXPECT} $@ left && \
	tmux -L $@ send-keys -t $@ "k" && \
	${EXPECT} $@ normal-keys && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right "u" KPEnter Escape && \
	${EXPECT} $@ left-modified && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter && \
	${EXPECT} $@ reentered && \
	tmux -L $@ send-keys -t $@ Escape ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B,C\n12,x,y\nt,,z\n\303\251,k,q\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# F2 cannot start from a value longer than the prompt line (the hint names :cell, which works
# outside edit mode); a paste that would not fit the line is refused; a value the screen may
# have cut short cannot be copied (whatever the bytes at the cut)
test-sheet-edit-mode-long: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{printf "A\n"; for(i=0;i<100;i++) printf "x"; print ""; for(i=0;i<33000;i++) printf "y"; print ""; \
	  for(i=0;i<8200;i++) printf "\360\237\230\200"; print ""; \
	  for(i=0;i<32762;i++) printf "z"; for(i=0;i<8;i++) printf "\223"; for(i=0;i<100;i++) printf "z"; print ""}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right F2 && \
	${EXPECT} $@ refused && \
	tmux -L $@ send-keys -t $@ C-c Down C-c && \
	${EXPECT} $@ copy-too-long && \
	tmux -L $@ send-keys -t $@ "a" C-v Enter && \
	${EXPECT} $@ paste-too-long && \
	tmux -L $@ send-keys -t $@ C-c && \
	${EXPECT} $@ copy-utf8-cut && \
	tmux -L $@ send-keys -t $@ Down C-c && \
	${EXPECT} $@ copy-invalid-utf8-cut && \
	tmux -L $@ send-keys -t $@ Escape ":q!" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a value cut short with no character boundary to cut at (bytes that are not UTF-8) is still
# known to be cut, so <ctrl>c refuses it and the <ctrl>v that follows has nothing to paste:
# the buffer stays unmodified and :q quits without asking
test-sheet-edit-mode-no-boundary: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "A,B"; for(i=0;i<33000;i++) printf "\200"; print ",x"}' > ${TMP_DIR}/$@.csv
	@cp ${TMP_DIR}/$@.csv ${TMP_DIR}/$@-orig.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "exec sh -c 'cd ${TMP_DIR} && ${PREFIX} $< $@.csv 2>$@.err; echo exit=\$$? >> $@.err'" && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Row #' && break; sleep 0.1; done && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right C-c && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Too long to copy' && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q 'Too long to copy' && \
	tmux -L $@ send-keys -t $@ Right C-v Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	cmp ${TMP_DIR}/$@-orig.csv ${TMP_DIR}/$@.csv && ${TEST_PASS} || (head -c 2000 ${TMP_DIR}/$@.err; ${TEST_FAIL}))

# clearing a cut cell whose shown prefix is empty (no character boundary to cut at) still
# records the edit, so <ctrl>s writes the empty value
test-sheet-edit-mode-no-boundary-clear: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "A,B"; for(i=0;i<33000;i++) printf "\200"; print ",x"}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "exec sh -c 'cd ${TMP_DIR} && ${PREFIX} $< $@.csv 2>$@.err; echo exit=\$$? >> $@.err'" && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Row #' && break; sleep 0.1; done && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right DC C-s && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q Saved && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q Saved && \
	tmux -L $@ send-keys -t $@ Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B\n,x\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || (head -c 2000 ${TMP_DIR}/$@.err; ${TEST_FAIL}))

# a cut cell whose shown prefix fits the edit prompt is still refused by F2 and by i, since
# storing the prefix would drop the rest of the value
test-sheet-edit-cut-refused: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "A,B"; printf "ab"; for(i=0;i<33000;i++) printf "\200"; print ",x"}' > ${TMP_DIR}/$@.csv
	@cp ${TMP_DIR}/$@.csv ${TMP_DIR}/$@-orig.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "exec sh -c 'cd ${TMP_DIR} && ${PREFIX} $< $@.csv 2>$@.err; echo exit=\$$? >> $@.err'" && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Row #' && break; sleep 0.1; done && \
	tmux -L $@ send-keys -t $@ "l" "i" && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Too long to edit' && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q 'Too long to edit' && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right C-c Left && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'Copied' && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q 'Copied' && \
	tmux -L $@ send-keys -t $@ F2 && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q 'EDIT.*Too long to edit' && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q 'EDIT.*Too long to edit' && \
	tmux -L $@ send-keys -t $@ Enter C-s && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q Saved && break; sleep 0.1; done && \
	tmux -L $@ send-keys -t $@ Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	cmp ${TMP_DIR}/$@-orig.csv ${TMP_DIR}/$@.csv && ${TEST_PASS} || (head -c 2000 ${TMP_DIR}/$@.err; ${TEST_FAIL}))

# the footer shows a value longer than the screen holds, clipped at the screen edge
test-sheet-footer-long: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "A"; for(i=0;i<1000;i++) printf "y"; print ""}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right && \
	${EXPECT} $@ footer && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a value too long for the status bar is shown whole, wrapped and centered, after the cursor has
# rested on it (two Rights: past the row-number column, then from the Name to the Description cell).
# The resting stage pins the delay: it compares the screen from 0.4 s after the key until the
# second of rest raises the overlay, so a box that came sooner fails here. The idle stage
# must show the box drawn in reverse, and the moved stage that the key which takes the box away
# still does its work (j moves to row 2)
test-sheet-overlay: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{s="The quick brown fox jumps over the lazy dog and then keeps running past the fence and into the deep dark woods where nobody ever goes at night"; printf "Name,Description\nalpha,%s\nbeta,short\n", s}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 24 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	sleep 2 && \
	tmux -L $@ send-keys -t $@ Right Right && \
	sleep 0.4 && \
	export EXPECT_TIMEOUT=1 && \
	${EXPECT} $@ resting && \
	export EXPECT_TIMEOUT=2 && \
	${EXPECT} $@ idle && \
	tmux -L $@ capture-pane -t $@ -p -e | grep -q -E "$$(printf '\033')\[7m[^[:cntrl:]]*The quick brown fox jumps over the lazy dog" && \
	tmux -L $@ send-keys -t $@ ":" && \
	${EXPECT} $@ prompt && \
	tmux -L $@ send-keys -t $@ Escape && \
	tmux -L $@ send-keys -t $@ "j" && \
	${EXPECT} $@ moved && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a value with more lines than the screen holds: the last row of the overlay says how many are not
# shown, and shrinking the pane to 21 columns makes that note fall back to a bare mark
test-sheet-overlay-more: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{s="the quick brown fox jumps over the lazy dog and then keeps running past the fence"; printf "Name,Description\nalpha,"; for(i=0;i<6;i++) printf "%s%s", (i?" ":""), s; print ""}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right Right && \
	export EXPECT_TIMEOUT=2 && \
	${EXPECT} $@ idle && \
	tmux -L $@ resize-window -t $@ -x 21 -y 5 && \
	tmux -L $@ send-keys -t $@ h l && \
	${EXPECT} $@ narrow && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# <ctrl>s in edit mode on a view without a file of its own asks for a name
test-sheet-edit-mode-saveas: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n2,y\n' > ${TMP_DIR}/$@.csv && rm -f ${TMP_DIR}/$@-out.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "f" "2" Enter && \
	${EXPECT} $@ filtered && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right Right "W" Tab C-s && \
	${EXPECT} $@ save-as && \
	tmux -L $@ send-keys -t $@ "$@-out.csv" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ Escape Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'Row #,A,B\n2,2,W\n' | cmp - ${TMP_DIR}/$@-out.csv && ${TEST_PASS} || ${TEST_FAIL})

# :q with unsaved changes quits only when "yes" is typed in full
test-sheet-quit-confirm: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "l" "i" C-a "Q" Enter ":q" Enter && \
	${EXPECT} $@ confirm && \
	tmux -L $@ send-keys -t $@ "y" Enter && \
	${EXPECT} $@ not-quit && \
	tmux -L $@ send-keys -t $@ ":q" Enter "YES" Enter && \
	${EXPECT} $@ not-quit && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${EXPECT} $@ confirm && \
	tmux -L $@ send-keys -t $@ Escape && \
	${EXPECT} $@ not-quit && \
	tmux -L $@ send-keys -t $@ ":q" Enter "yes" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B\n1,x\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# <ctrl>c outside edit mode copies the cell under the cursor to the system clipboard, and
# cancels a prompt or a picker. The clipboard tool is a fake on PATH, so what it receives can
# be compared here without touching the developer's own clipboard
test-sheet-ctrl-c: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,xyzzy\n' > ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/$@-bin ${TMP_DIR}/$@-clip.txt && mkdir -p ${TMP_DIR}/$@-bin
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/pbcopy
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/wl-copy
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/xclip
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/xsel
	@chmod +x ${TMP_DIR}/$@-bin/pbcopy ${TMP_DIR}/$@-bin/wl-copy ${TMP_DIR}/$@-bin/xclip ${TMP_DIR}/$@-bin/xsel
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec env PATH=${TMP_DIR}/$@-bin:$$PATH CLIP_FILE=${TMP_DIR}/$@-clip.txt ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right Right C-c && \
	${EXPECT} $@ copied && \
	printf 'xyzzy' | cmp -s - ${TMP_DIR}/$@-clip.txt && \
	tmux -L $@ send-keys -t $@ ":" "abc" && \
	${EXPECT} $@ prompt && \
	tmux -L $@ send-keys -t $@ C-c && \
	${EXPECT} $@ prompt-cancelled && \
	tmux -L $@ send-keys -t $@ ":compare" Enter && \
	${EXPECT} $@ picker && \
	tmux -L $@ send-keys -t $@ C-c && \
	${EXPECT} $@ picker-cancelled && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# with no clipboard tool to run, <ctrl>c says so instead of copying
test-sheet-copy-no-tool: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,xyzzy\n' > ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/$@-empty && mkdir -p ${TMP_DIR}/$@-empty
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec env PATH=${TMP_DIR}/$@-empty ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right Right C-c && \
	${EXPECT} $@ no-tool && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right C-c && \
	${EXPECT} $@ edit-mode-no-tool && \
	tmux -L $@ send-keys -t $@ Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a cell too long for the screen buffer holds only a prefix, so <ctrl>c refuses it
test-sheet-copy-too-long: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@awk 'BEGIN{print "A,B"; for(i=0;i<40000;i++) printf "z"; print ",x"}' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right C-c && \
	${EXPECT} $@ too-long && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a message has its few seconds on the status line, gives the line back to the usual hint, and
# reappears when the action is repeated. The timeout is set small here so the test is quick;
# every other sheet test keeps messages up for as long as it needs them. The filtered stage is
# the one that would fail if a message a worker installs itself (the "(N filtered rows) " note)
# were born expired
test-sheet-status-timeout: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,xyzzy\n' > ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/$@-bin && mkdir -p ${TMP_DIR}/$@-bin
	@printf '#!/bin/sh\n/bin/cat > /dev/null\n' > ${TMP_DIR}/$@-bin/pbcopy
	@printf '#!/bin/sh\n/bin/cat > /dev/null\n' > ${TMP_DIR}/$@-bin/wl-copy
	@printf '#!/bin/sh\n/bin/cat > /dev/null\n' > ${TMP_DIR}/$@-bin/xclip
	@printf '#!/bin/sh\n/bin/cat > /dev/null\n' > ${TMP_DIR}/$@-bin/xsel
	@chmod +x ${TMP_DIR}/$@-bin/pbcopy ${TMP_DIR}/$@-bin/wl-copy ${TMP_DIR}/$@-bin/xclip ${TMP_DIR}/$@-bin/xsel
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec env PATH=${TMP_DIR}/$@-bin:$$PATH ZSVSHEET_STATUS_TIMEOUT_MS=1500 ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ Right Right C-c && \
	${EXPECT} $@ copied && \
	sleep 0.5 && \
	${EXPECT} $@ copied && \
	sleep 2 && \
	${EXPECT} $@ gone && \
	tmux -L $@ send-keys -t $@ C-c && \
	${EXPECT} $@ copied && \
	tmux -L $@ send-keys -t $@ "f" "xyzzy" Enter && \
	${EXPECT} $@ filtered && \
	sleep 2 && \
	${EXPECT} $@ filtered-gone && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# edit mode: <ctrl>c copies the cell, both into sheet's own clipboard and to the one the fakes
# on PATH stand in for, and <ctrl>v pastes into a cell (line breaks kept, though the screen shows
# them as spaces) or, while typing, into the value (line breaks become spaces)
test-sheet-edit-mode-clipboard: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n2,y\n"\303\251\nb",w\n' > ${TMP_DIR}/$@.csv
	@rm -rf ${TMP_DIR}/$@-bin ${TMP_DIR}/$@-clip.txt && mkdir -p ${TMP_DIR}/$@-bin
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/pbcopy
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/wl-copy
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/xclip
	@printf '#!/bin/sh\n/bin/cat > "$$CLIP_FILE"\n' > ${TMP_DIR}/$@-bin/xsel
	@chmod +x ${TMP_DIR}/$@-bin/pbcopy ${TMP_DIR}/$@-bin/wl-copy ${TMP_DIR}/$@-bin/xclip ${TMP_DIR}/$@-bin/xsel
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 6 -d -s $@ "cd ${TMP_DIR} && exec env PATH=${TMP_DIR}/$@-bin:$$PATH CLIP_FILE=${TMP_DIR}/$@-clip.txt ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ ":editmode" Enter Right C-v && \
	${EXPECT} $@ empty && \
	tmux -L $@ send-keys -t $@ C-c && \
	${EXPECT} $@ copied && \
	printf '1' | cmp -s - ${TMP_DIR}/$@-clip.txt && \
	tmux -L $@ send-keys -t $@ Right C-v && \
	${EXPECT} $@ pasted && \
	tmux -L $@ send-keys -t $@ Down "z" C-v && \
	${EXPECT} $@ typing-paste && \
	tmux -L $@ send-keys -t $@ "w" C-c Escape Left C-v C-s && \
	${EXPECT} $@ typing-copy && \
	tmux -L $@ send-keys -t $@ Down C-c Right C-v Up "z" C-v Enter C-s && \
	${EXPECT} $@ multiline && \
	tmux -L $@ send-keys -t $@ F2 && \
	${EXPECT} $@ f2-multiline && \
	tmux -L $@ send-keys -t $@ Escape ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	printf 'A,B\n1,1\nz1w,z\303\251 b\n"\303\251\nb","\303\251\nb"\n' | cmp - ${TMP_DIR}/$@.csv && ${TEST_PASS} || ${TEST_FAIL})

# <ctrl>\ quits at once and leaves the terminal usable (canonical input and echo back on), even
# under a parent shell that does not restore the terminal itself
test-sheet-quit-signal: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv && rm -f ${TMP_DIR}/$@.stty
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec sh -c 'trap : QUIT; ${PREFIX} $< $@.csv; stty -a > $@.stty'" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ 'C-\' && \
	${WAIT_FOR_EXIT} $@ && \
	grep -Eq '(^| )icanon' ${TMP_DIR}/$@.stty && grep -Eq '(^| )echo( |$$)' ${TMP_DIR}/$@.stty && ${TEST_PASS} || ${TEST_FAIL})

# a parent that ignores SIGQUIT keeps <ctrl>\ from ending sheet, and keys still reach it
test-sheet-quit-signal-ignored: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec sh -c \"trap '' QUIT; exec ${PREFIX} $< $@.csv\"" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ 'C-\' l l && \
	${EXPECT} $@ still-running && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# <ctrl>z suspends to the shell (job control) and fg resumes, with keys still reaching the app.
# The shell is sh, which every platform has; a stopped job leaves $? above 128
test-sheet-suspend: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && exec env PS1='$$ ' SHEET='${PREFIX} $<' sh -i" && \
	${TYPE_COMMAND} $@ '$$SHEET $@.csv' && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ C-z && \
	${TYPE_COMMAND} $@ '[ $$? -gt 128 ] && echo SUSP""ENDED' && \
	for i in $$(seq 50); do tmux -L $@ capture-pane -p -t $@ | grep -q SUSPENDED && break; sleep 0.1; done && \
	tmux -L $@ capture-pane -p -t $@ | grep -q SUSPENDED && \
	${TYPE_COMMAND} $@ fg && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ l l && \
	${EXPECT} $@ moved && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${TYPE_COMMAND} $@ exit && \
	${WAIT_FOR_EXIT} $@ && ${TEST_PASS} || ${TEST_FAIL})

# a buffer without a file (help) saves its cells
test-sheet-edit-help: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@printf 'A,B\n1,x\n' > ${TMP_DIR}/$@.csv
	@rm -f ${TMP_DIR}/$@-out.csv
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 5 -d -s $@ "cd ${TMP_DIR} && ${PREFIX} $< $@.csv" && \
	${EXPECT} $@ open && \
	tmux -L $@ send-keys -t $@ "?" "j" "l" "i" C-a "QUIT" Enter ":w $@-out.csv" Enter && \
	${EXPECT} $@ saved && \
	tmux -L $@ send-keys -t $@ ":q" Enter && \
	${WAIT_FOR_EXIT} $@ && \
	head -2 ${TMP_DIR}/$@-out.csv | cmp - expected/$@-out.csv && ${TEST_PASS} || ${TEST_FAIL})

benchmark-sheet-index: ${BUILD_DIR}/bin/zsv_sheet${EXE} ${TIMINGS_CSV}
	@${TEST_INIT}
	@if [ "${BIG_FILE}" = "none" ]; then \
		echo "Need to set BIG_FILE to a CSV file; ideally 10GB+"; \
	fi
	@echo 'set-option default-terminal "${TMUX_TERM}"' > ~/.tmux.conf
	@(tmux -L $@ kill-server 2>/dev/null; rm -f ${TMP_DIR}/tmux-$$(id -u)/$@ 2>/dev/null; \
	tmux -L $@ new-session -x 80 -y 50 -d -s "$@" "${PREFIX} $< ${BIG_FILE}" && \
	${EXPECT} $@ indexed && \
	tmux -L $@ send-keys -t $@ "G" && \
	${EXPECT} $@ && ${TEST_PASS} || ${TEST_FAIL})
