aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRalph Amissah <ralph.amissah@gmail.com>2026-09-07 10:20:22 -0400
committerRalph Amissah <ralph.amissah@gmail.com>2026-09-09 17:22:34 -0400
commit284b02440be4454355ec2910766ae48d88b52415 (patch)
tree8409bdb54a72f409b9a1744b2d9b984f8dfb7a0a
parentocda db: full text index and three views (diff)
test: cross-check .ssp & ocda db
cross-check .ssp & ocda (abstraction) db against each other To prevent drift, this compares them directly to detect one writer gaining a field and the other not (rather than each against a committed reference, so there is no new fixture to keep current) For every sample document it generates both artefacts and asserts: - the same number of objects, and the same number in each of the eight sections - for 32 single valued properties, that the count of the .ssp property equals the count of rows where the corresponding column is set - for the four repeatable properties (.image, .stow_link, .anchor_tag, .lev4_subtoc), that the count equals the rows in its table - that every image the abstraction referred to and found is carried in the files table - that the text of the first and last citable body object is byte identical in the two - that the database says what it is (metadata schema.version) SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod \ nix shell "nixpkgs#sqlite" -c ./test/test-abstraction-db.sh ./bin/spine-ldc A sqlite error on a missing column is reported as a mismatch rather than aborting the run. The other half of this step, one shared field description behind both writers, is not done here: the cross-check gives the same guarantee without touching either writer, and the question becomes moot if, as planned, the database comes to be generated from the .ssp through the loader. Added with -f: test/ is covered by .gitignore's catch-all, as test/test-abstraction-ssp.sh was before it. (assisted by Claude-Code)
-rw-r--r--org/test_shell_script_ssp_document_abstraction.org202
-rw-r--r--org/tests_for_document_abstraction_shell_scripts.org438
-rwxr-xr-xtest/test-abstraction-db.sh228
3 files changed, 666 insertions, 202 deletions
diff --git a/org/test_shell_script_ssp_document_abstraction.org b/org/test_shell_script_ssp_document_abstraction.org
deleted file mode 100644
index ccbe6dd..0000000
--- a/org/test_shell_script_ssp_document_abstraction.org
+++ /dev/null
@@ -1,202 +0,0 @@
--*- mode: org -*-
-#+TITLE: test shell script ssp document abstraction
-#+DESCRIPTION: env envrc used by make & nix
-#+FILETAGS: :spine:build:tools:
-#+AUTHOR: Ralph Amissah
-#+EMAIL: [[mailto:ralph.amissah@gmail.com][ralph.amissah@gmail.com]]
-#+COPYRIGHT: Copyright (C) 2015 (continuously updated, current 2026) Ralph Amissah
-#+LANGUAGE: en
-#+STARTUP: content hideblocks hidestars noindent entitiespretty
-#+PROPERTY: header-args+ :eval never-export :exports code
-#+PROPERTY: header-args+ :noweb yes :padline no
-#+PROPERTY: header-args+ :results silent :cache no
-#+PROPERTY: header-args+ :mkdirp yes
-#+OPTIONS: H:3 num:nil toc:t \n:t ::t |:t ^:nil -:t f:t *:t
-- magic single double-quote → " ← FIX changes hilighting behavior (occuring
- after it) in org document. INVESTIGATE (org-mode CONFIG?) FIND & FIX
-
-* test shell script ssp document abstraction test/test-abstraction-ssp.sh
-
-#+HEADER: :tangle ../test/test-abstraction-ssp.sh
-#+HEADER: :tangle-mode (identity #o755)
-#+HEADER: :shebang "#!/usr/bin/env sh"
-#+BEGIN_SRC shell
-# test-abstraction-ssp.sh
-#
-# Regression test for spine's document abstraction.
-# Generates .ssp files for all sample documents and diffs
-# against committed reference files.
-# Also checks that a parallel run and a serial run of the same documents
-# produce identical .ssp (guards against per-document state leaking
-# between documents or between worker threads).
-#
-# Usage:
-# ./test/test-abstraction-ssp.sh # uses result/bin/spine
-# ./test/test-abstraction-ssp.sh ./bin/spine-ldc # specify binary
-#
-# Exit codes:
-# 0 all .ssp files match reference
-# 1 differences found (printed to stdout)
-# 2 missing reference directory (run with --generate first)
-#
-# To generate/update reference files:
-# ./test/test-abstraction-ssp.sh --generate
-# ./test/test-abstraction-ssp.sh --generate ./bin/spine-ldc
-
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
-
-# $SpinePOD names the sample pod directory, relative to the spine directory.
-# Unset, "$SPINE_DIR/$SpinePOD" is the spine directory itself, which exists,
-# so the "sample documents not found" check below would not catch it and
-# spine would be handed every file in the spine tree.
-if [ -z "$SpinePOD" ]; then
- echo "ERROR: \$SpinePOD is not set." >&2
- echo " Set it to the sample pod directory, relative to $SPINE_DIR" >&2
- echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
- exit 2
-fi
-
-SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
-if [ -d "$SAMPLES_RAW" ]; then
- SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
-else
- SAMPLES_DIR="$SAMPLES_RAW"
-fi
-REF_DIR="$SCRIPT_DIR/reference/abstraction"
-TMP_DIR="$SCRIPT_DIR/current/abstraction"
-
-# parse arguments
-GENERATE=false
-SPINE_BIN=""
-for arg in "$@"; do
- case "$arg" in
- --generate) GENERATE=true ;;
- *) SPINE_BIN="$arg" ;;
- esac
-done
-
-# find spine binary
-if [ -z "$SPINE_BIN" ]; then
- if [ -x "$SPINE_DIR/result/bin/spine" ]; then
- SPINE_BIN="$SPINE_DIR/result/bin/spine"
- elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then
- SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
- elif [ -x "$SPINE_DIR/bin/spine" ]; then
- SPINE_BIN="$SPINE_DIR/bin/spine"
- else
- echo "ERROR: spine binary not found. Specify path as argument." >&2
- exit 2
- fi
-fi
-
-# check samples exist
-if [ ! -d "$SAMPLES_DIR" ]; then
- echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
- exit 2
-fi
-
-echo "spine binary: $SPINE_BIN"
-echo "samples: $SAMPLES_DIR"
-
-if [ "$GENERATE" = true ]; then
- echo "Generating reference .ssp files..."
- rm -rf "$REF_DIR"
- mkdir -p "$REF_DIR"
- $SPINE_BIN --show-abstraction --skip-output --output="$SCRIPT_DIR/reference" "$SAMPLES_DIR"/* 2>&1 | tail -1
- # flatten language subdirs into reference dir
- find "$SCRIPT_DIR/reference" -name "*.ssp" ! -path "$REF_DIR/*" -exec mv {} "$REF_DIR/" \;
- # clean up empty language dirs
- find "$SCRIPT_DIR/reference" -mindepth 1 -type d -empty -delete 2>/dev/null || true
- COUNT=$(ls "$REF_DIR"/*.ssp 2>/dev/null | wc -l)
- echo "Generated $COUNT reference .ssp files in $REF_DIR"
- exit 0
-fi
-
-# check reference exists
-if [ ! -d "$REF_DIR" ] || [ -z "$(ls "$REF_DIR"/*.ssp 2>/dev/null)" ]; then
- echo "ERROR: no reference files found at $REF_DIR" >&2
- echo "Run with --generate first to create reference files." >&2
- exit 2
-fi
-
-# generate current .ssp files
-echo "Generating current .ssp files..."
-rm -rf "$TMP_DIR"
-mkdir -p "$TMP_DIR"
-$SPINE_BIN --abstraction --show-abstraction --skip-output --output="$SCRIPT_DIR/current" "$SAMPLES_DIR"/* 2>&1 | tail -1
-# flatten
-find "$SCRIPT_DIR/current" -name "*.ssp" ! -path "$TMP_DIR/*" -exec mv {} "$TMP_DIR/" \;
-find "$SCRIPT_DIR/current" -mindepth 1 -type d -empty -delete 2>/dev/null || true
-
-# generate the same .ssp serially, to check that parallel and serial
-# processing agree (per-document state leaking between documents, or
-# between worker threads, shows up here)
-SER_DIR="$SCRIPT_DIR/current-serial/abstraction"
-echo "Generating current .ssp files (serial)..."
-rm -rf "$SCRIPT_DIR/current-serial"
-mkdir -p "$SER_DIR"
-$SPINE_BIN --show-abstraction --serial --skip-output --output="$SCRIPT_DIR/current-serial" "$SAMPLES_DIR"/* 2>&1 | tail -1
-find "$SCRIPT_DIR/current-serial" -name "*.ssp" ! -path "$SER_DIR/*" -exec mv {} "$SER_DIR/" \;
-find "$SCRIPT_DIR/current-serial" -mindepth 1 -type d -empty -delete 2>/dev/null || true
-
-# diff
-echo "Comparing against reference..."
-FAILURES=0
-for ref_file in "$REF_DIR"/*.ssp; do
- basename=$(basename "$ref_file")
- cur_file="$TMP_DIR/$basename"
- if [ ! -f "$cur_file" ]; then
- echo "MISSING: $basename (in reference but not generated)"
- FAILURES=$((FAILURES + 1))
- continue
- fi
- if ! diff -q "$ref_file" "$cur_file" > /dev/null 2>&1; then
- echo "CHANGED: $basename"
- diff --unified=3 "$ref_file" "$cur_file" | head -30
- echo " ..."
- FAILURES=$((FAILURES + 1))
- fi
-done
-
-# check for new files not in reference
-for cur_file in "$TMP_DIR"/*.ssp; do
- basename=$(basename "$cur_file")
- ref_file="$REF_DIR/$basename"
- if [ ! -f "$ref_file" ]; then
- echo "NEW: $basename (generated but not in reference)"
- FAILURES=$((FAILURES + 1))
- fi
-done
-
-# parallel vs serial determinism
-for cur_file in "$TMP_DIR"/*.ssp; do
- basename=$(basename "$cur_file")
- ser_file="$SER_DIR/$basename"
- if [ ! -f "$ser_file" ]; then
- echo "MISSING (serial run): $basename"
- FAILURES=$((FAILURES + 1))
- continue
- fi
- if ! diff -q "$cur_file" "$ser_file" > /dev/null 2>&1; then
- echo "NON-DETERMINISTIC: $basename (parallel and serial runs differ)"
- diff --unified=3 "$cur_file" "$ser_file" | head -30
- echo " ..."
- FAILURES=$((FAILURES + 1))
- fi
-done
-
-# clean up
-rm -rf "$SCRIPT_DIR/current" "$SCRIPT_DIR/current-serial"
-
-if [ "$FAILURES" -eq 0 ]; then
- REF_COUNT=$(ls "$REF_DIR"/*.ssp | wc -l)
- echo "PASS: all $REF_COUNT .ssp files match reference (parallel and serial agree)"
- exit 0
-else
- echo "FAIL: $FAILURES difference(s) found"
- exit 1
-fi
-#+END_SRC
diff --git a/org/tests_for_document_abstraction_shell_scripts.org b/org/tests_for_document_abstraction_shell_scripts.org
new file mode 100644
index 0000000..df02509
--- /dev/null
+++ b/org/tests_for_document_abstraction_shell_scripts.org
@@ -0,0 +1,438 @@
+-*- mode: org -*-
+#+TITLE: test shell script ssp document abstraction
+#+DESCRIPTION: env envrc used by make & nix
+#+FILETAGS: :spine:build:tools:
+#+AUTHOR: Ralph Amissah
+#+EMAIL: [[mailto:ralph.amissah@gmail.com][ralph.amissah@gmail.com]]
+#+COPYRIGHT: Copyright (C) 2015 (continuously updated, current 2026) Ralph Amissah
+#+LANGUAGE: en
+#+STARTUP: content hideblocks hidestars noindent entitiespretty
+#+PROPERTY: header-args+ :eval never-export :exports code
+#+PROPERTY: header-args+ :noweb yes :padline no
+#+PROPERTY: header-args+ :results silent :cache no
+#+PROPERTY: header-args+ :mkdirp yes
+#+OPTIONS: H:3 num:nil toc:t \n:t ::t |:t ^:nil -:t f:t *:t
+- magic single double-quote → " ← FIX changes hilighting behavior (occuring
+ after it) in org document. INVESTIGATE (org-mode CONFIG?) FIND & FIX
+
+* test shell scripts document abstraction
+** ssp document abstraction test/test-abstraction-ssp.sh
+
+#+HEADER: :tangle ../test/test-abstraction-ssp.sh
+#+HEADER: :tangle-mode (identity #o755)
+#+HEADER: :shebang "#!/usr/bin/env sh"
+#+BEGIN_SRC shell
+# test-abstraction-ssp.sh
+#
+# Regression test for spine's document abstraction.
+# Generates .ssp files for all sample documents and diffs
+# against committed reference files.
+# Also checks that a parallel run and a serial run of the same documents
+# produce identical .ssp (guards against per-document state leaking
+# between documents or between worker threads).
+#
+# Usage:
+# ./test/test-abstraction-ssp.sh # uses result/bin/spine
+# ./test/test-abstraction-ssp.sh ./bin/spine-ldc # specify binary
+#
+# Exit codes:
+# 0 all .ssp files match reference
+# 1 differences found (printed to stdout)
+# 2 missing reference directory (run with --generate first)
+#
+# To generate/update reference files:
+# ./test/test-abstraction-ssp.sh --generate
+# ./test/test-abstraction-ssp.sh --generate ./bin/spine-ldc
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+# $SpinePOD names the sample pod directory, relative to the spine directory.
+# Unset, "$SPINE_DIR/$SpinePOD" is the spine directory itself, which exists,
+# so the "sample documents not found" check below would not catch it and
+# spine would be handed every file in the spine tree.
+if [ -z "$SpinePOD" ]; then
+ echo "ERROR: \$SpinePOD is not set." >&2
+ echo " Set it to the sample pod directory, relative to $SPINE_DIR" >&2
+ echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
+ exit 2
+fi
+
+SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
+if [ -d "$SAMPLES_RAW" ]; then
+ SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
+else
+ SAMPLES_DIR="$SAMPLES_RAW"
+fi
+REF_DIR="$SCRIPT_DIR/reference/abstraction"
+TMP_DIR="$SCRIPT_DIR/current/abstraction"
+
+# parse arguments
+GENERATE=false
+SPINE_BIN=""
+for arg in "$@"; do
+ case "$arg" in
+ --generate) GENERATE=true ;;
+ *) SPINE_BIN="$arg" ;;
+ esac
+done
+
+# find spine binary
+if [ -z "$SPINE_BIN" ]; then
+ if [ -x "$SPINE_DIR/result/bin/spine" ]; then
+ SPINE_BIN="$SPINE_DIR/result/bin/spine"
+ elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then
+ SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
+ elif [ -x "$SPINE_DIR/bin/spine" ]; then
+ SPINE_BIN="$SPINE_DIR/bin/spine"
+ else
+ echo "ERROR: spine binary not found. Specify path as argument." >&2
+ exit 2
+ fi
+fi
+
+# check samples exist
+if [ ! -d "$SAMPLES_DIR" ]; then
+ echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
+ exit 2
+fi
+
+echo "spine binary: $SPINE_BIN"
+echo "samples: $SAMPLES_DIR"
+
+if [ "$GENERATE" = true ]; then
+ echo "Generating reference .ssp files..."
+ rm -rf "$REF_DIR"
+ mkdir -p "$REF_DIR"
+ $SPINE_BIN --show-abstraction --skip-output --output="$SCRIPT_DIR/reference" "$SAMPLES_DIR"/* 2>&1 | tail -1
+ # flatten language subdirs into reference dir
+ find "$SCRIPT_DIR/reference" -name "*.ssp" ! -path "$REF_DIR/*" -exec mv {} "$REF_DIR/" \;
+ # clean up empty language dirs
+ find "$SCRIPT_DIR/reference" -mindepth 1 -type d -empty -delete 2>/dev/null || true
+ COUNT=$(ls "$REF_DIR"/*.ssp 2>/dev/null | wc -l)
+ echo "Generated $COUNT reference .ssp files in $REF_DIR"
+ exit 0
+fi
+
+# check reference exists
+if [ ! -d "$REF_DIR" ] || [ -z "$(ls "$REF_DIR"/*.ssp 2>/dev/null)" ]; then
+ echo "ERROR: no reference files found at $REF_DIR" >&2
+ echo "Run with --generate first to create reference files." >&2
+ exit 2
+fi
+
+# generate current .ssp files
+echo "Generating current .ssp files..."
+rm -rf "$TMP_DIR"
+mkdir -p "$TMP_DIR"
+$SPINE_BIN --abstraction --show-abstraction --skip-output --output="$SCRIPT_DIR/current" "$SAMPLES_DIR"/* 2>&1 | tail -1
+# flatten
+find "$SCRIPT_DIR/current" -name "*.ssp" ! -path "$TMP_DIR/*" -exec mv {} "$TMP_DIR/" \;
+find "$SCRIPT_DIR/current" -mindepth 1 -type d -empty -delete 2>/dev/null || true
+
+# generate the same .ssp serially, to check that parallel and serial
+# processing agree (per-document state leaking between documents, or
+# between worker threads, shows up here)
+SER_DIR="$SCRIPT_DIR/current-serial/abstraction"
+echo "Generating current .ssp files (serial)..."
+rm -rf "$SCRIPT_DIR/current-serial"
+mkdir -p "$SER_DIR"
+$SPINE_BIN --show-abstraction --serial --skip-output --output="$SCRIPT_DIR/current-serial" "$SAMPLES_DIR"/* 2>&1 | tail -1
+find "$SCRIPT_DIR/current-serial" -name "*.ssp" ! -path "$SER_DIR/*" -exec mv {} "$SER_DIR/" \;
+find "$SCRIPT_DIR/current-serial" -mindepth 1 -type d -empty -delete 2>/dev/null || true
+
+# diff
+echo "Comparing against reference..."
+FAILURES=0
+for ref_file in "$REF_DIR"/*.ssp; do
+ basename=$(basename "$ref_file")
+ cur_file="$TMP_DIR/$basename"
+ if [ ! -f "$cur_file" ]; then
+ echo "MISSING: $basename (in reference but not generated)"
+ FAILURES=$((FAILURES + 1))
+ continue
+ fi
+ if ! diff -q "$ref_file" "$cur_file" > /dev/null 2>&1; then
+ echo "CHANGED: $basename"
+ diff --unified=3 "$ref_file" "$cur_file" | head -30
+ echo " ..."
+ FAILURES=$((FAILURES + 1))
+ fi
+done
+
+# check for new files not in reference
+for cur_file in "$TMP_DIR"/*.ssp; do
+ basename=$(basename "$cur_file")
+ ref_file="$REF_DIR/$basename"
+ if [ ! -f "$ref_file" ]; then
+ echo "NEW: $basename (generated but not in reference)"
+ FAILURES=$((FAILURES + 1))
+ fi
+done
+
+# parallel vs serial determinism
+for cur_file in "$TMP_DIR"/*.ssp; do
+ basename=$(basename "$cur_file")
+ ser_file="$SER_DIR/$basename"
+ if [ ! -f "$ser_file" ]; then
+ echo "MISSING (serial run): $basename"
+ FAILURES=$((FAILURES + 1))
+ continue
+ fi
+ if ! diff -q "$cur_file" "$ser_file" > /dev/null 2>&1; then
+ echo "NON-DETERMINISTIC: $basename (parallel and serial runs differ)"
+ diff --unified=3 "$cur_file" "$ser_file" | head -30
+ echo " ..."
+ FAILURES=$((FAILURES + 1))
+ fi
+done
+
+# clean up
+rm -rf "$SCRIPT_DIR/current" "$SCRIPT_DIR/current-serial"
+
+if [ "$FAILURES" -eq 0 ]; then
+ REF_COUNT=$(ls "$REF_DIR"/*.ssp | wc -l)
+ echo "PASS: all $REF_COUNT .ssp files match reference (parallel and serial agree)"
+ exit 0
+else
+ echo "FAIL: $FAILURES difference(s) found"
+ exit 1
+fi
+#+END_SRC
+
+** db document abstraction test/test-abstraction-db.sh
+
+#+HEADER: :tangle ../test/test-abstraction-db.sh
+#+HEADER: :tangle-mode (identity #o755)
+#+HEADER: :shebang "#!/usr/bin/env sh"
+#+BEGIN_SRC shell
+# test-abstraction-db.sh
+#
+# Cross-check for spine's two serialisations of the document abstraction:
+# the .ssp text file and the <doc>.abstraction.db sqlite database.
+#
+# It generates both for every sample document and asserts that they agree
+# object for object and field for field. There is no committed reference
+# set: the two artefacts check each other, which is what catches the thing
+# that went wrong before, one writer gaining a field and the other not.
+#
+# Usage:
+# SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod \
+# ./test/test-abstraction-db.sh ./bin/spine-ldc
+#
+# Exit codes:
+# 0 the two agree for every document
+# 1 disagreements found (printed)
+# 2 set-up problem (no $SpinePOD, no binary, no sqlite3, no samples)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+if [ -z "$SpinePOD" ]; then
+ echo "ERROR: \$SpinePOD is not set." >&2
+ echo " Set it to the sample pod directory, relative to $SPINE_DIR" >&2
+ echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
+ exit 2
+fi
+
+SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
+if [ -d "$SAMPLES_RAW" ]; then
+ SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
+else
+ SAMPLES_DIR="$SAMPLES_RAW"
+fi
+
+SPINE_BIN="${1:-}"
+if [ -z "$SPINE_BIN" ]; then
+ if [ -x "$SPINE_DIR/result/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/result/bin/spine"
+ elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
+ elif [ -x "$SPINE_DIR/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/bin/spine"
+ else
+ echo "ERROR: spine binary not found. Specify path as argument." >&2
+ exit 2
+ fi
+fi
+
+if ! command -v sqlite3 > /dev/null 2>&1; then
+ echo "ERROR: sqlite3 not on PATH." >&2
+ echo " e.g. nix shell \"nixpkgs#sqlite\" -c ./test/test-abstraction-db.sh $SPINE_BIN" >&2
+ exit 2
+fi
+
+if [ ! -d "$SAMPLES_DIR" ]; then
+ echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
+ exit 2
+fi
+
+OUT_DIR="$SCRIPT_DIR/current-db"
+
+echo "spine binary: $SPINE_BIN"
+echo "samples: $SAMPLES_DIR"
+echo "Generating .ssp and .abstraction.db ..."
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+$SPINE_BIN --show-abstraction --show-abstraction-db --skip-output \
+ --output="$OUT_DIR" "$SAMPLES_DIR"/* 2>&1 | tail -1
+
+FAILURES=0
+DOCS=0
+
+# ssp property -> sqlite predicate counting the same thing
+# "<property>|<predicate>"
+CHECKS="
+children|children IS NOT NULL
+ancestors|ancestors IS NOT NULL
+ancestors_collapsed|ancestors_collapsed IS NOT NULL
+dom_status|dom_status IS NOT NULL
+dom_status_collapsed|dom_status_collapsed IS NOT NULL
+last_descendant|last_descendant_ocn != 0
+parent_lev|parent_lev != 0
+heading_lev_collapsed|heading_lev_collapsed IS NOT NULL
+section|is_of_section IS NOT NULL AND is_of_section != section
+part|is_of_part IS NOT NULL
+is_of_type|is_of_type IS NOT NULL
+attrib|attrib IS NOT NULL
+meta_lang|meta_lang IS NOT NULL
+meta_syntax|meta_syntax IS NOT NULL
+sha256|sha256 IS NOT NULL
+heading_ancestors_text|heading_ancestors_text IS NOT NULL
+segment|segment IS NOT NULL
+segment_prev|segment_prev IS NOT NULL
+segment_next|segment_next IS NOT NULL
+segment_epub|segment_epub IS NOT NULL
+anchor|anchor IS NOT NULL AND anchor != segment
+heading_lev_anchor|heading_lev_anchor IS NOT NULL
+table_cols|table_cols IS NOT NULL
+table_widths|table_widths IS NOT NULL
+table_aligns|table_aligns IS NOT NULL
+dummy: true|dummy_heading = 1
+ocn_off: true|object_number_off = 1
+bullet: true|bullet = 1
+table_header: true|table_header = 1
+code_linenumbers: true|code_linenumbers = 1
+lang|lang IS NOT NULL
+indent|indent_base != 0 OR indent_hang != 0
+"
+
+# repeatable ssp property -> child table
+LISTS="
+image|object_images
+stow_link|object_links
+anchor_tag|object_anchors
+lev4_subtoc|object_subtoc
+"
+
+report() { echo " MISMATCH $1: .ssp $2, db $3"; }
+
+for ssp in "$OUT_DIR"/*/abstraction/*.ssp; do
+ [ -f "$ssp" ] || continue
+ db="${ssp%.ssp}.abstraction.db"
+ base=$(basename "$ssp" .ssp)
+ if [ ! -f "$db" ]; then
+ echo "MISSING db for $base"
+ FAILURES=$((FAILURES + 1))
+ continue
+ fi
+ DOCS=$((DOCS + 1))
+ doc_fail=0
+
+ # object population
+ s_objs=$(grep -c '^\[' "$ssp" || true)
+ d_objs=$(sqlite3 "$db" "select count(*) from objects;" 2>/dev/null || echo ERR)
+ if [ "$s_objs" != "$d_objs" ]; then
+ echo "$base"; report "objects" "$s_objs" "$d_objs"; doc_fail=1
+ fi
+
+ # per section
+ for sec in head toc body endnotes glossary bibliography bookindex blurb; do
+ s_sec=$(awk -v s="@$sec {" '$0==s{f=1;next} /^\}$/{f=0} f&&/^\[/{n++} END{print n+0}' "$ssp")
+ d_sec=$(sqlite3 "$db" "select count(*) from objects where section='$sec';" 2>/dev/null || echo ERR)
+ if [ "$s_sec" != "$d_sec" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ report "section $sec" "$s_sec" "$d_sec"; doc_fail=1
+ fi
+ done
+
+ # single valued properties
+ echo "$CHECKS" | while IFS='|' read -r prop pred; do
+ [ -z "$prop" ] && continue
+ case "$prop" in
+ *": true") s_n=$(grep -c "^\.$prop\$" "$ssp" || true) ;;
+ *) s_n=$(grep -c "^\.$prop: " "$ssp" || true) ;;
+ esac
+ d_n=$(sqlite3 "$db" "select count(*) from objects where $pred;" 2>/dev/null || echo ERR)
+ if [ "$s_n" != "$d_n" ]; then
+ echo "$base: MISMATCH .$prop: .ssp $s_n, db $d_n"
+ fi
+ done > "$OUT_DIR/.props.$$"
+ if [ -s "$OUT_DIR/.props.$$" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ sed 's/^[^:]*: / /' "$OUT_DIR/.props.$$"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.props.$$"
+
+ # repeatable properties against their tables
+ echo "$LISTS" | while IFS='|' read -r prop table; do
+ [ -z "$prop" ] && continue
+ s_n=$(grep -c "^\.$prop: " "$ssp" || true)
+ d_n=$(sqlite3 "$db" "select count(*) from $table;" 2>/dev/null || echo ERR)
+ if [ "$s_n" != "$d_n" ]; then
+ echo "$base: MISMATCH .$prop: .ssp $s_n, $table $d_n"
+ fi
+ done > "$OUT_DIR/.lists.$$"
+ if [ -s "$OUT_DIR/.lists.$$" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ sed 's/^[^:]*: / /' "$OUT_DIR/.lists.$$"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.lists.$$"
+
+ # every image the abstraction referred to and found is carried in the file
+ want=$(sqlite3 "$db" "select count(distinct name) from object_images where missing = 0;" 2>/dev/null || echo ERR)
+ have=$(sqlite3 "$db" "select count(*) from files f where f.role='image'
+ and exists (select 1 from object_images i where i.name = f.name);" 2>/dev/null || echo ERR)
+ if [ "$want" != "$have" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ report "images carried" "$want referenced" "$have stored"; doc_fail=1
+ fi
+
+ # the text itself, on the first and last citable object
+ for pick in "min" "max"; do
+ ocn=$(sqlite3 "$db" "select $pick(ocn) from objects where ocn > 0 and section='body';" 2>/dev/null || echo "")
+ [ -z "$ocn" ] && continue
+ sqlite3 "$db" "select text from objects where ocn=$ocn and section='body';" > "$OUT_DIR/.dbtxt.$$" 2>/dev/null || true
+ awk -v o="[$ocn] " 'index($0,o)==1{f=1;next} f&&/^$/{exit} f&&/^\| /{print substr($0,3)}' \
+ "$ssp" > "$OUT_DIR/.ssptxt.$$"
+ if ! diff -q "$OUT_DIR/.dbtxt.$$" "$OUT_DIR/.ssptxt.$$" > /dev/null 2>&1; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ echo " MISMATCH text of ocn $ocn"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.dbtxt.$$" "$OUT_DIR/.ssptxt.$$"
+ done
+
+ # the file says what it is
+ sv=$(sqlite3 "$db" "select value from metadata where key='schema.version';" 2>/dev/null || echo '')
+ if [ -z "$sv" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ echo " MISSING metadata schema.version"; doc_fail=1
+ fi
+
+ FAILURES=$((FAILURES + doc_fail))
+done
+
+rm -rf "$OUT_DIR"
+
+if [ "$FAILURES" -eq 0 ]; then
+ echo "PASS: .ssp and .abstraction.db agree for all $DOCS documents"
+ exit 0
+else
+ echo "FAIL: $FAILURES document(s) disagree"
+ exit 1
+fi
+#+END_SRC
diff --git a/test/test-abstraction-db.sh b/test/test-abstraction-db.sh
new file mode 100755
index 0000000..91748a3
--- /dev/null
+++ b/test/test-abstraction-db.sh
@@ -0,0 +1,228 @@
+#!/usr/bin/env sh
+# test-abstraction-db.sh
+#
+# Cross-check for spine's two serialisations of the document abstraction:
+# the .ssp text file and the <doc>.abstraction.db sqlite database.
+#
+# It generates both for every sample document and asserts that they agree
+# object for object and field for field. There is no committed reference
+# set: the two artefacts check each other, which is what catches the thing
+# that went wrong before, one writer gaining a field and the other not.
+#
+# Usage:
+# SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod \
+# ./test/test-abstraction-db.sh ./bin/spine-ldc
+#
+# Exit codes:
+# 0 the two agree for every document
+# 1 disagreements found (printed)
+# 2 set-up problem (no $SpinePOD, no binary, no sqlite3, no samples)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+if [ -z "$SpinePOD" ]; then
+ echo "ERROR: \$SpinePOD is not set." >&2
+ echo " Set it to the sample pod directory, relative to $SPINE_DIR" >&2
+ echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
+ exit 2
+fi
+
+SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
+if [ -d "$SAMPLES_RAW" ]; then
+ SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
+else
+ SAMPLES_DIR="$SAMPLES_RAW"
+fi
+
+SPINE_BIN="${1:-}"
+if [ -z "$SPINE_BIN" ]; then
+ if [ -x "$SPINE_DIR/result/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/result/bin/spine"
+ elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
+ elif [ -x "$SPINE_DIR/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/bin/spine"
+ else
+ echo "ERROR: spine binary not found. Specify path as argument." >&2
+ exit 2
+ fi
+fi
+
+if ! command -v sqlite3 > /dev/null 2>&1; then
+ echo "ERROR: sqlite3 not on PATH." >&2
+ echo " e.g. nix shell \"nixpkgs#sqlite\" -c ./test/test-abstraction-db.sh $SPINE_BIN" >&2
+ exit 2
+fi
+
+if [ ! -d "$SAMPLES_DIR" ]; then
+ echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
+ exit 2
+fi
+
+OUT_DIR="$SCRIPT_DIR/current-db"
+
+echo "spine binary: $SPINE_BIN"
+echo "samples: $SAMPLES_DIR"
+echo "Generating .ssp and .abstraction.db ..."
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+$SPINE_BIN --show-abstraction --show-abstraction-db --skip-output \
+ --output="$OUT_DIR" "$SAMPLES_DIR"/* 2>&1 | tail -1
+
+FAILURES=0
+DOCS=0
+
+# ssp property -> sqlite predicate counting the same thing
+# "<property>|<predicate>"
+CHECKS="
+children|children IS NOT NULL
+ancestors|ancestors IS NOT NULL
+ancestors_collapsed|ancestors_collapsed IS NOT NULL
+dom_status|dom_status IS NOT NULL
+dom_status_collapsed|dom_status_collapsed IS NOT NULL
+last_descendant|last_descendant_ocn != 0
+parent_lev|parent_lev != 0
+heading_lev_collapsed|heading_lev_collapsed IS NOT NULL
+section|is_of_section IS NOT NULL AND is_of_section != section
+part|is_of_part IS NOT NULL
+is_of_type|is_of_type IS NOT NULL
+attrib|attrib IS NOT NULL
+meta_lang|meta_lang IS NOT NULL
+meta_syntax|meta_syntax IS NOT NULL
+sha256|sha256 IS NOT NULL
+heading_ancestors_text|heading_ancestors_text IS NOT NULL
+segment|segment IS NOT NULL
+segment_prev|segment_prev IS NOT NULL
+segment_next|segment_next IS NOT NULL
+segment_epub|segment_epub IS NOT NULL
+anchor|anchor IS NOT NULL AND anchor != segment
+heading_lev_anchor|heading_lev_anchor IS NOT NULL
+table_cols|table_cols IS NOT NULL
+table_widths|table_widths IS NOT NULL
+table_aligns|table_aligns IS NOT NULL
+dummy: true|dummy_heading = 1
+ocn_off: true|object_number_off = 1
+bullet: true|bullet = 1
+table_header: true|table_header = 1
+code_linenumbers: true|code_linenumbers = 1
+lang|lang IS NOT NULL
+indent|indent_base != 0 OR indent_hang != 0
+"
+
+# repeatable ssp property -> child table
+LISTS="
+image|object_images
+stow_link|object_links
+anchor_tag|object_anchors
+lev4_subtoc|object_subtoc
+"
+
+report() { echo " MISMATCH $1: .ssp $2, db $3"; }
+
+for ssp in "$OUT_DIR"/*/abstraction/*.ssp; do
+ [ -f "$ssp" ] || continue
+ db="${ssp%.ssp}.abstraction.db"
+ base=$(basename "$ssp" .ssp)
+ if [ ! -f "$db" ]; then
+ echo "MISSING db for $base"
+ FAILURES=$((FAILURES + 1))
+ continue
+ fi
+ DOCS=$((DOCS + 1))
+ doc_fail=0
+
+ # object population
+ s_objs=$(grep -c '^\[' "$ssp" || true)
+ d_objs=$(sqlite3 "$db" "select count(*) from objects;" 2>/dev/null || echo ERR)
+ if [ "$s_objs" != "$d_objs" ]; then
+ echo "$base"; report "objects" "$s_objs" "$d_objs"; doc_fail=1
+ fi
+
+ # per section
+ for sec in head toc body endnotes glossary bibliography bookindex blurb; do
+ s_sec=$(awk -v s="@$sec {" '$0==s{f=1;next} /^\}$/{f=0} f&&/^\[/{n++} END{print n+0}' "$ssp")
+ d_sec=$(sqlite3 "$db" "select count(*) from objects where section='$sec';" 2>/dev/null || echo ERR)
+ if [ "$s_sec" != "$d_sec" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ report "section $sec" "$s_sec" "$d_sec"; doc_fail=1
+ fi
+ done
+
+ # single valued properties
+ echo "$CHECKS" | while IFS='|' read -r prop pred; do
+ [ -z "$prop" ] && continue
+ case "$prop" in
+ *": true") s_n=$(grep -c "^\.$prop\$" "$ssp" || true) ;;
+ *) s_n=$(grep -c "^\.$prop: " "$ssp" || true) ;;
+ esac
+ d_n=$(sqlite3 "$db" "select count(*) from objects where $pred;" 2>/dev/null || echo ERR)
+ if [ "$s_n" != "$d_n" ]; then
+ echo "$base: MISMATCH .$prop: .ssp $s_n, db $d_n"
+ fi
+ done > "$OUT_DIR/.props.$$"
+ if [ -s "$OUT_DIR/.props.$$" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ sed 's/^[^:]*: / /' "$OUT_DIR/.props.$$"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.props.$$"
+
+ # repeatable properties against their tables
+ echo "$LISTS" | while IFS='|' read -r prop table; do
+ [ -z "$prop" ] && continue
+ s_n=$(grep -c "^\.$prop: " "$ssp" || true)
+ d_n=$(sqlite3 "$db" "select count(*) from $table;" 2>/dev/null || echo ERR)
+ if [ "$s_n" != "$d_n" ]; then
+ echo "$base: MISMATCH .$prop: .ssp $s_n, $table $d_n"
+ fi
+ done > "$OUT_DIR/.lists.$$"
+ if [ -s "$OUT_DIR/.lists.$$" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ sed 's/^[^:]*: / /' "$OUT_DIR/.lists.$$"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.lists.$$"
+
+ # every image the abstraction referred to and found is carried in the file
+ want=$(sqlite3 "$db" "select count(distinct name) from object_images where missing = 0;" 2>/dev/null || echo ERR)
+ have=$(sqlite3 "$db" "select count(*) from files f where f.role='image'
+ and exists (select 1 from object_images i where i.name = f.name);" 2>/dev/null || echo ERR)
+ if [ "$want" != "$have" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ report "images carried" "$want referenced" "$have stored"; doc_fail=1
+ fi
+
+ # the text itself, on the first and last citable object
+ for pick in "min" "max"; do
+ ocn=$(sqlite3 "$db" "select $pick(ocn) from objects where ocn > 0 and section='body';" 2>/dev/null || echo "")
+ [ -z "$ocn" ] && continue
+ sqlite3 "$db" "select text from objects where ocn=$ocn and section='body';" > "$OUT_DIR/.dbtxt.$$" 2>/dev/null || true
+ awk -v o="[$ocn] " 'index($0,o)==1{f=1;next} f&&/^$/{exit} f&&/^\| /{print substr($0,3)}' \
+ "$ssp" > "$OUT_DIR/.ssptxt.$$"
+ if ! diff -q "$OUT_DIR/.dbtxt.$$" "$OUT_DIR/.ssptxt.$$" > /dev/null 2>&1; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ echo " MISMATCH text of ocn $ocn"
+ doc_fail=1
+ fi
+ rm -f "$OUT_DIR/.dbtxt.$$" "$OUT_DIR/.ssptxt.$$"
+ done
+
+ # the file says what it is
+ sv=$(sqlite3 "$db" "select value from metadata where key='schema.version';" 2>/dev/null || echo '')
+ if [ -z "$sv" ]; then
+ [ $doc_fail -eq 0 ] && echo "$base"
+ echo " MISSING metadata schema.version"; doc_fail=1
+ fi
+
+ FAILURES=$((FAILURES + doc_fail))
+done
+
+rm -rf "$OUT_DIR"
+
+if [ "$FAILURES" -eq 0 ]; then
+ echo "PASS: .ssp and .abstraction.db agree for all $DOCS documents"
+ exit 0
+else
+ echo "FAIL: $FAILURES document(s) disagree"
+ exit 1
+fi