Compile, link and run every example in the documentation
libakgl exports 157 functions and the only user-facing documentation was a FAQ in README.md whose examples did not compile: two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids. That is not a reader routing around typos. It is what happens to samples that nothing executes. This is akbasic's documentation harness with the interpreter taken out and the ability to link and run put in. The contract is a set of fence info strings, so an example is ordinary markdown that still highlights on the forge: ```c compiled with -fsyntax-only -Wall -Werror ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post ```c run=NAME linked against akgl and run headless ```c excerpt=PATH must still appear verbatim in PATH ```c screenshot=NAME also the source of docs/images/NAME.png ```json kind=KIND loaded through the real akgl_*_load_json ```output the exact stdout of the runnable block above it `run=` is why this links at all: -fsyntax-only proves a call typechecks, not that the startup order works or that an ATTEMPT block gives back what it took. `json kind=` exists because the asset formats are documented in prose and read by four loaders with nothing tying the two together -- util/assets/littleguy.json is already invalid against the loader it ships with. A fence with no info string is a hard error, and so is an unknown one. The failure mode this exists to prevent is passing because it quietly ran nothing, so an unannotated block is a missing decision rather than a default. Exit status is the number of failed examples; 2 for a usage error, which is a different thing and has to be distinguishable. Proven to fail on all ten of its failure modes -- untagged fence, non-compiling snippet, stale excerpt, orphan output block, unknown info string, run= output mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling preload= -- because a check that has never failed has not been tested. `-Werror` here although AKGL_WERROR stays off for the library: that option is off so a new compiler's diagnostic cannot break a consumer's build, and a doc snippet is not a consumer. A sample that warns is a sample that teaches the warning. Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI, deliberately: documentation goes stale because the code moved, not because somebody edited a chapter. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
680
tests/docs_examples.sh
Executable file
680
tests/docs_examples.sh
Executable file
@@ -0,0 +1,680 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Compile, link, run and cross-check every example in docs/.
|
||||
#
|
||||
# libakgl exports 156 functions and, before this existed, the only user-facing
|
||||
# documentation was a FAQ in README.md whose examples did not compile: two
|
||||
# unbalanced PASS() calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in
|
||||
# any dialect, a stray `9` inside a bitmask expression, an `int screenwidth =
|
||||
# NULL`, and -- in the first snippet a reader ever saw -- the exact strncpy call
|
||||
# AGENTS.md forbids. The prose had drifted the same way: two claims in the
|
||||
# headers were false against src/.
|
||||
#
|
||||
# That is not a reader routing around typos. It is what happens to samples and
|
||||
# prose that nothing executes. AGENTS.md already makes the argument in another
|
||||
# context -- "A test that has not failed has not been tested" -- and the sibling
|
||||
# akbasic repository solved this exact problem, so this is its harness with the
|
||||
# interpreter taken out and the ability to link and run put in.
|
||||
#
|
||||
# Documentation goes stale because the *code* moved, not because somebody edited
|
||||
# a chapter, so this runs on every ctest rather than behind a docs path filter.
|
||||
#
|
||||
# **Exit status is the number of failed examples**, the house convention. 2 for a
|
||||
# usage or setup error, which is a different thing and has to be distinguishable.
|
||||
#
|
||||
# The contract with the documentation is a set of fence info strings. They are
|
||||
# ordinary markdown, they render as syntax highlighting on the forge, and they
|
||||
# sit next to the thing they describe. docs/MAINTENANCE.md is the reference; the
|
||||
# short version:
|
||||
#
|
||||
# ```c a translation unit: compile it with -fsyntax-only
|
||||
# ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post
|
||||
# ```c run=NAME wrapped, linked against akgl, and run headless
|
||||
# ```c excerpt=PATH must appear verbatim in PATH; not compiled
|
||||
# ```c screenshot=NAME also the source of docs/images/NAME.png
|
||||
# ```json kind=KIND loaded through the real akgl_*_load_json
|
||||
# ```output the exact stdout of the runnable block above it
|
||||
# ```sh run in a sandbox; must exit 0
|
||||
# ```sh setup=NAME the same, after tests/docs_setups/NAME.sh
|
||||
# ```sh norun destructive, networked, or re-enters this suite
|
||||
# ```cmake never executed; hand-maintained, by decision
|
||||
# ```text a diagram or a captured dump; nothing to execute
|
||||
# ```norun shown, never executed, whatever it is
|
||||
#
|
||||
# A block with **no** info string is a hard error, and so is one whose info
|
||||
# string this script does not know. That is deliberate: the failure mode the
|
||||
# whole harness exists to avoid is passing because it quietly ran nothing, so an
|
||||
# unannotated block is a missing decision rather than a default.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=""
|
||||
CFLAGS_FILE=""
|
||||
LDFLAGS_FILE=""
|
||||
CHECKJSON=""
|
||||
|
||||
usage()
|
||||
{
|
||||
cat >&2 <<'EOF'
|
||||
usage: docs_examples.sh --root DIR [--cflags-file FILE] [--ldflags-file FILE]
|
||||
[--checkjson PATH] [FILE...]
|
||||
|
||||
--root DIR repository root; documentation paths are relative to it
|
||||
--cflags-file FILE one compiler flag per line, for the ```c blocks
|
||||
--ldflags-file FILE one linker argument per line, for the ```c run= blocks
|
||||
--checkjson PATH the built akgl_docs_checkjson, for the ```json blocks
|
||||
FILE... which documents to check (default: docs/*.md)
|
||||
|
||||
Exit status is the number of failed examples, or 2 for a usage or setup error.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) ROOT="$2"; shift 2 ;;
|
||||
--cflags-file) CFLAGS_FILE="$2"; shift 2 ;;
|
||||
--ldflags-file) LDFLAGS_FILE="$2"; shift 2 ;;
|
||||
--checkjson) CHECKJSON="$2"; shift 2 ;;
|
||||
--help|-h) usage ;;
|
||||
--*) echo "unknown option $1" >&2; usage ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "${ROOT}" ] || usage
|
||||
|
||||
# Every path is forced absolute before anything else runs, because this script
|
||||
# cd's into sandbox directories. akbasic learned that the hard way: a relative
|
||||
# --root could not find tests/docs_setups, which is invisible under CTest -- it
|
||||
# passes absolute paths -- and immediate for anybody running one document by
|
||||
# hand.
|
||||
for _var in CHECKJSON CFLAGS_FILE LDFLAGS_FILE; do
|
||||
_val="${!_var}"
|
||||
case "${_val}" in
|
||||
""|/*) ;;
|
||||
*) printf -v "${_var}" '%s' "${PWD}/${_val}" ;;
|
||||
esac
|
||||
done
|
||||
unset _var _val
|
||||
|
||||
cd "${ROOT}" || exit 2
|
||||
ROOT="${PWD}"
|
||||
|
||||
DOCS=("$@")
|
||||
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||
# The default list is a glob and is allowed to match nothing: the chapters
|
||||
# are written in parallel with this, and a harness that refuses to configure
|
||||
# until docs/ exists is a harness nobody can land first. A document named on
|
||||
# the command line is a different case entirely -- see below.
|
||||
DOCS=()
|
||||
for _doc in docs/*.md; do
|
||||
[ -r "${_doc}" ] && DOCS+=("${_doc}")
|
||||
done
|
||||
unset _doc
|
||||
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||
echo "ran: nothing -- no documents matched docs/*.md"
|
||||
echo "PASS: there is no documentation to check yet"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Refuse a named document that is not there rather than checking nothing and
|
||||
# passing. An empty argument is the specific case that got here in akbasic: a
|
||||
# CMake generator expression evaluating to nothing still contributes an empty
|
||||
# argument, which looked like a filename and silently replaced the whole list.
|
||||
for _doc in "${DOCS[@]}"; do
|
||||
if [ ! -r "${_doc}" ]; then
|
||||
echo "FAIL: no document at \"${_doc}\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
unset _doc
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
# The compiler flags the ```c blocks need. CMake writes them, because the
|
||||
# include path is transitive through akerror, akstdlib, SDL3 and jansson, and
|
||||
# hardcoding it here would rot exactly the way the documentation does.
|
||||
CFLAGS=()
|
||||
if [ -n "${CFLAGS_FILE}" ] && [ -r "${CFLAGS_FILE}" ]; then
|
||||
while IFS= read -r _flag; do
|
||||
[ -n "${_flag}" ] && CFLAGS+=("${_flag}")
|
||||
done < "${CFLAGS_FILE}"
|
||||
# The preludes reach their shared include set as <docs_prelude.h>, and the
|
||||
# unit is compiled from a scratch directory, so the search path has to say
|
||||
# where that is.
|
||||
CFLAGS+=("-I${ROOT}/tests/docs_preludes")
|
||||
fi
|
||||
|
||||
LDFLAGS=()
|
||||
if [ -n "${LDFLAGS_FILE}" ] && [ -r "${LDFLAGS_FILE}" ]; then
|
||||
while IFS= read -r _flag; do
|
||||
[ -n "${_flag}" ] && LDFLAGS+=("${_flag}")
|
||||
done < "${LDFLAGS_FILE}"
|
||||
fi
|
||||
unset _flag
|
||||
|
||||
CC="${CC:-cc}"
|
||||
|
||||
FAILURES=0
|
||||
declare -A RAN=([c]=0 [run]=0 [output]=0 [excerpt]=0 [json]=0 [sh]=0 [screenshot]=0)
|
||||
declare -A SKIPPED=([norun]=0 [cmake]=0 [text]=0 [nocc]=0 [nold]=0 [nojson]=0)
|
||||
|
||||
# Named `json` blocks, so a later one can name them in `preload=`. Reset per
|
||||
# document: a name is a fact about one chapter, and letting chapter 13 reach
|
||||
# into chapter 11 would make the order documents are checked in load-bearing.
|
||||
declare -A JSON_KIND_OF=()
|
||||
declare -A JSON_BODY_OF=()
|
||||
|
||||
# Every failure names the file and the line of the block's *opening* fence, so
|
||||
# the message points at the thing to edit rather than at this script.
|
||||
function fail()
|
||||
{
|
||||
local where="$1"; shift
|
||||
echo "FAIL ${where}: $*" >&2
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
# Show a byte-exact comparison. `cat -A` because the errors this catches are
|
||||
# trailing spaces and missing newlines, which a plain diff renders invisibly.
|
||||
function show_diff()
|
||||
{
|
||||
local want="$1" got="$2"
|
||||
echo "--- expected ---" >&2
|
||||
cat -A "${want}" >&2
|
||||
echo "--- actual ---" >&2
|
||||
cat -A "${got}" >&2
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- extraction
|
||||
#
|
||||
# One pass per document, writing each block's body to ${WORK}/blocks/NNNN and a
|
||||
# line to ${WORK}/blocks/index. Keeping the bodies in files rather than in shell
|
||||
# variables means a block containing a NUL, a backslash or an unbalanced quote
|
||||
# is carried through untouched.
|
||||
function extract()
|
||||
{
|
||||
local doc="$1" out="$2"
|
||||
|
||||
awk -v OUT="${out}" -v INDEX="${out}/index" '
|
||||
function pad(n) { return sprintf("%04d", n) }
|
||||
/^```/ {
|
||||
if ( !inblock ) {
|
||||
inblock = 1
|
||||
tag = substr($0, 4)
|
||||
sub(/[ \t]+$/, "", tag)
|
||||
start = NR
|
||||
count++
|
||||
body = OUT "/" pad(count)
|
||||
printf "" > body
|
||||
next
|
||||
}
|
||||
if ( $0 == "```" ) {
|
||||
close(body)
|
||||
printf "%s\t%d\t%s\n", pad(count), start, tag >> INDEX
|
||||
inblock = 0
|
||||
next
|
||||
}
|
||||
}
|
||||
inblock { print >> body }
|
||||
' "${doc}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- helpers
|
||||
|
||||
# The value of attribute $1 in an info string $2, or empty. `wrap=akglbody` and
|
||||
# `kind=sprite` are both read this way.
|
||||
function attr()
|
||||
{
|
||||
local name="$1" info="$2" word
|
||||
for word in ${info}; do
|
||||
case "${word}" in
|
||||
"${name}"=*) echo "${word#*=}"; return ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# True when a bare word appears in an info string. Used for `norun`.
|
||||
function has_word()
|
||||
{
|
||||
local want="$1" info="$2" word
|
||||
for word in ${info}; do
|
||||
[ "${word}" = "${want}" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Strip C comments and collapse whitespace, so an excerpt can be compared to the
|
||||
# header it came from without either side having to carry the other's doc
|
||||
# comments or indentation. The bit-flag tables in actor.h and iterator.h are
|
||||
# hand-aligned on purpose -- scripts/reindent.el goes out of its way not to
|
||||
# disturb them -- so an excerpt of one has to survive being re-aligned.
|
||||
function normalise_c()
|
||||
{
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
out = ""
|
||||
while ( length(line) > 0 ) {
|
||||
if ( incomment ) {
|
||||
i = index(line, "*/")
|
||||
if ( i == 0 ) { line = ""; break }
|
||||
incomment = 0
|
||||
line = substr(line, i + 2)
|
||||
continue
|
||||
}
|
||||
i = index(line, "/*")
|
||||
j = index(line, "//")
|
||||
if ( j > 0 && (i == 0 || j < i) ) {
|
||||
out = out substr(line, 1, j - 1)
|
||||
line = ""
|
||||
break
|
||||
}
|
||||
if ( i == 0 ) { out = out line; line = ""; break }
|
||||
out = out substr(line, 1, i - 1)
|
||||
incomment = 1
|
||||
line = substr(line, i + 2)
|
||||
}
|
||||
printf "%s ", out
|
||||
}
|
||||
END { printf "\n" }
|
||||
' "$1" | tr -s ' \t' ' ' | sed -e 's/^ //' -e 's/ $//'
|
||||
}
|
||||
|
||||
# Run tests/docs_setups/NAME.sh in ${1}, with the repository root as its only
|
||||
# argument. An example that loads an asset needs that asset; putting the fixture
|
||||
# in a setup script rather than in the chapter keeps the example the shape a
|
||||
# reader wants to see.
|
||||
function run_setup()
|
||||
{
|
||||
local sandbox="$1" setup="$2" where="$3"
|
||||
|
||||
[ -n "${setup}" ] || return 0
|
||||
if [ ! -r "${ROOT}/tests/docs_setups/${setup}.sh" ]; then
|
||||
fail "${where}" "setup=${setup} names no tests/docs_setups/${setup}.sh"
|
||||
return 1
|
||||
fi
|
||||
if ! ( cd "${sandbox}" && bash "${ROOT}/tests/docs_setups/${setup}.sh" "${ROOT}" ) \
|
||||
>/dev/null 2>&1; then
|
||||
fail "${where}" "setup=${setup} failed"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Assemble the translation unit: prelude, a #line directive naming the markdown,
|
||||
# the body, and the closing half of the prelude. Writes ${WORK}/unit.c.
|
||||
#
|
||||
# The #line is what makes a diagnostic read `docs/14-physics.md:112: error: ...`
|
||||
# rather than pointing into a scratch file nobody can open.
|
||||
function build_unit()
|
||||
{
|
||||
local where="$1" body="$2" wrap="$3" doc="$4" line="$5"
|
||||
local unit="${WORK}/unit.c"
|
||||
|
||||
: > "${unit}"
|
||||
if [ -n "${wrap}" ]; then
|
||||
if [ ! -r "${ROOT}/tests/docs_preludes/${wrap}.pre" ]; then
|
||||
fail "${where}" "names no tests/docs_preludes/${wrap}.pre"
|
||||
return 1
|
||||
fi
|
||||
cat "${ROOT}/tests/docs_preludes/${wrap}.pre" >> "${unit}"
|
||||
fi
|
||||
printf '#line %d "%s"\n' "$((line + 1))" "${doc}" >> "${unit}"
|
||||
cat "${body}" >> "${unit}"
|
||||
if [ -n "${wrap}" ] && [ -r "${ROOT}/tests/docs_preludes/${wrap}.post" ]; then
|
||||
printf '#line 1 "tests/docs_preludes/%s.post"\n' "${wrap}" >> "${unit}"
|
||||
cat "${ROOT}/tests/docs_preludes/${wrap}.post" >> "${unit}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- block kinds
|
||||
|
||||
# A C snippet, compiled and not linked: what most of these examples are for is
|
||||
# showing the API, and the API is exactly what -fsyntax-only checks.
|
||||
#
|
||||
# -Werror here, unlike the library. AGENTS.md keeps AKGL_WERROR off by default
|
||||
# because libakgl is consumed with add_subdirectory() and a new compiler's
|
||||
# diagnostic should not break somebody else's build. A documentation snippet is
|
||||
# not a consumer, and a sample that warns is a sample that teaches the warning.
|
||||
#
|
||||
# gnu99 rather than c99: akerror.h uses PATH_MAX, which <limits.h> hides under
|
||||
# __STRICT_ANSI__, so a stricter dialect would fail on the dependency rather
|
||||
# than on the example.
|
||||
function run_c()
|
||||
{
|
||||
local where="$1" body="$2" info="$3" doc="$4" line="$5"
|
||||
local wrap log="${WORK}/cc.log"
|
||||
|
||||
if [ ${#CFLAGS[@]} -eq 0 ]; then
|
||||
SKIPPED[nocc]=$((SKIPPED[nocc] + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
wrap="$(attr wrap "${info}")"
|
||||
build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return
|
||||
|
||||
if "${CC}" -fsyntax-only -std=gnu99 -Wall -Werror \
|
||||
"${CFLAGS[@]}" "${WORK}/unit.c" > "${log}" 2>&1; then
|
||||
RAN[c]=$((RAN[c] + 1))
|
||||
else
|
||||
fail "${where}" "does not compile"
|
||||
sed -n '1,25p' "${log}" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# A C snippet linked into a real program and run headless.
|
||||
#
|
||||
# This is the reason to port the harness at all for a game library. -fsyntax-only
|
||||
# proves a call typechecks; it does not prove the startup order works, that a
|
||||
# sprite loads, or that an ATTEMPT block releases what it acquired. A run= block
|
||||
# asserts a clean exit *and* that nothing reported a raised context on stderr,
|
||||
# because a libakgl program can fail and still exit 0.
|
||||
function run_c_run()
|
||||
{
|
||||
local where="$1" body="$2" info="$3" doc="$4" line="$5" expected="$6"
|
||||
local wrap log="${WORK}/cc.log" prog="${WORK}/prog"
|
||||
local sandbox="${WORK}/run" got="${WORK}/got" errout="${WORK}/stderr" status=0
|
||||
|
||||
if [ ${#CFLAGS[@]} -eq 0 ] || [ ${#LDFLAGS[@]} -eq 0 ]; then
|
||||
SKIPPED[nold]=$((SKIPPED[nold] + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
wrap="$(attr run "${info}")"
|
||||
build_unit "${where}" "${body}" "${wrap}" "${doc}" "${line}" || return
|
||||
|
||||
rm -f "${prog}"
|
||||
if ! "${CC}" -std=gnu99 -Wall -Werror "${CFLAGS[@]}" \
|
||||
-o "${prog}" "${WORK}/unit.c" "${LDFLAGS[@]}" > "${log}" 2>&1; then
|
||||
fail "${where}" "does not build"
|
||||
sed -n '1,25p' "${log}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
rm -rf "${sandbox}"
|
||||
mkdir -p "${sandbox}"
|
||||
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
|
||||
|
||||
# The same headless forcing scripts/memcheck.sh uses, so the answer does not
|
||||
# depend on whose desktop it ran on.
|
||||
( cd "${sandbox}" && \
|
||||
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
|
||||
"${prog}" ) > "${got}" 2> "${errout}"
|
||||
status=$?
|
||||
RAN[run]=$((RAN[run] + 1))
|
||||
|
||||
if [ "${status}" -ne 0 ]; then
|
||||
fail "${where}" "the example exited ${status}"
|
||||
sed -n '1,20p' "${got}" "${errout}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# An error that was ignored, or one that reached libakerror's unhandled
|
||||
# handler, is a failure even where the process still exited 0. Both print a
|
||||
# recognisable line, and both mean the example did not do what it says.
|
||||
if grep -qE '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}"; then
|
||||
fail "${where}" "the example raised an error it did not handle"
|
||||
grep -E '\*\* IGNORED ERROR \*\*|Unhandled Error' "${errout}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# stdout only. libakgl logs registry activity to stderr -- three lines every
|
||||
# time a sprite is created -- and folding that into the comparison would put
|
||||
# library chatter into a chapter's expected output.
|
||||
if [ -n "${expected}" ]; then
|
||||
RAN[output]=$((RAN[output] + 1))
|
||||
if ! cmp -s "${expected}" "${got}"; then
|
||||
fail "${where}" "output does not match the block below it"
|
||||
show_diff "${expected}" "${got}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# A declaration echoed from a header. Compiling it would redefine the type, so
|
||||
# the check is that it still says what the header says -- which is the drift
|
||||
# that actually happens, and the one a compile could not catch.
|
||||
function run_excerpt()
|
||||
{
|
||||
local where="$1" body="$2" path="$3"
|
||||
|
||||
if [ ! -r "${path}" ]; then
|
||||
fail "${where}" "excerpt=${path} names no such file"
|
||||
return
|
||||
fi
|
||||
RAN[excerpt]=$((RAN[excerpt] + 1))
|
||||
if ! normalise_c "${path}" | grep -qF -- "$(normalise_c "${body}")"; then
|
||||
fail "${where}" "no longer appears in ${path}"
|
||||
echo "--- the documentation says ---" >&2
|
||||
cat "${body}" >&2
|
||||
echo "--- ${path} has drifted from it; comments and whitespace are ignored ---" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# An asset file, run through the loader that really reads it.
|
||||
#
|
||||
# The formats are documented in prose and read by src/sprite.c, src/character.c,
|
||||
# src/tilemap.c and src/registry.c, with nothing tying the two together. That gap
|
||||
# is not hypothetical: util/assets/littleguy.json is already invalid against the
|
||||
# loader it ships beside. tools/docs_checkjson.c closes it.
|
||||
function run_json()
|
||||
{
|
||||
local where="$1" body="$2" info="$3"
|
||||
local kind name preload preloads target
|
||||
local sandbox="${WORK}/json" log="${WORK}/json.log"
|
||||
local args=()
|
||||
|
||||
kind="$(attr kind "${info}")"
|
||||
if [ -z "${kind}" ]; then
|
||||
fail "${where}" "a json block with no kind= (sprite, character, tilemap or properties)"
|
||||
return
|
||||
fi
|
||||
|
||||
# A named block can be preloaded by a later one. Recorded whether or not the
|
||||
# validator is available, so omitting --checkjson does not turn a bad
|
||||
# preload= into a different failure.
|
||||
name="$(attr name "${info}")"
|
||||
if [ -n "${name}" ]; then
|
||||
JSON_KIND_OF["${name}"]="${kind}"
|
||||
JSON_BODY_OF["${name}"]="${body}"
|
||||
fi
|
||||
|
||||
if [ -z "${CHECKJSON}" ] || [ ! -x "${CHECKJSON}" ]; then
|
||||
SKIPPED[nojson]=$((SKIPPED[nojson] + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
rm -rf "${sandbox}"
|
||||
mkdir -p "${sandbox}"
|
||||
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
|
||||
|
||||
# Three of the four formats are not self-contained: a character names sprites
|
||||
# that have to be in the registry already, and a tilemap names characters.
|
||||
# `preload=` lists the earlier named blocks that have to be loaded first, in
|
||||
# order -- which puts the dependency in front of the reader rather than in a
|
||||
# setup script they cannot see.
|
||||
preloads="$(attr preload "${info}")"
|
||||
for preload in ${preloads//,/ }; do
|
||||
if [ -z "${JSON_KIND_OF[${preload}]:-}" ]; then
|
||||
fail "${where}" "preload=${preload} names no earlier \`json name=${preload}\` block in this document"
|
||||
return
|
||||
fi
|
||||
cp "${JSON_BODY_OF[${preload}]}" "${sandbox}/${preload}.json"
|
||||
args+=("${JSON_KIND_OF[${preload}]}" "${preload}.json")
|
||||
done
|
||||
|
||||
target="${name:-docs_example}"
|
||||
cp "${body}" "${sandbox}/${target}.json"
|
||||
args+=("${kind}" "${target}.json")
|
||||
|
||||
if ( cd "${sandbox}" && \
|
||||
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
|
||||
"${CHECKJSON}" "${args[@]}" ) > "${log}" 2>&1; then
|
||||
RAN[json]=$((RAN[json] + 1))
|
||||
else
|
||||
fail "${where}" "is not valid ${kind} JSON as far as the loader is concerned"
|
||||
sed -n '1,25p' "${log}" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# A shell command, in a sandbox.
|
||||
function run_sh()
|
||||
{
|
||||
local where="$1" body="$2" info="$3" expected="$4"
|
||||
local sandbox="${WORK}/sh" script="${WORK}/block.sh" got="${WORK}/got"
|
||||
|
||||
rm -rf "${sandbox}"
|
||||
mkdir -p "${sandbox}"
|
||||
run_setup "${sandbox}" "$(attr setup "${info}")" "${where}" || return
|
||||
|
||||
# A leading "$ " is a prompt in a transcript, not part of the command.
|
||||
sed -e 's/^\$ //' "${body}" > "${script}"
|
||||
if ! ( cd "${sandbox}" && bash -e "${script}" ) > "${got}" 2>&1; then
|
||||
fail "${where}" "the command failed"
|
||||
sed -n '1,20p' "${got}" >&2
|
||||
return
|
||||
fi
|
||||
RAN[sh]=$((RAN[sh] + 1))
|
||||
|
||||
if [ -n "${expected}" ]; then
|
||||
RAN[output]=$((RAN[output] + 1))
|
||||
if ! cmp -s "${expected}" "${got}"; then
|
||||
fail "${where}" "output does not match the block below it"
|
||||
show_diff "${expected}" "${got}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ the pass
|
||||
for DOC in "${DOCS[@]}"; do
|
||||
BLOCKS="${WORK}/blocks"
|
||||
rm -rf "${BLOCKS}"
|
||||
mkdir -p "${BLOCKS}"
|
||||
: > "${BLOCKS}/index"
|
||||
extract "${DOC}" "${BLOCKS}"
|
||||
JSON_KIND_OF=()
|
||||
JSON_BODY_OF=()
|
||||
|
||||
# Read the index into arrays first. An `output` block belongs to the block
|
||||
# before it, so each iteration has to be able to look one ahead.
|
||||
CLAIMED=0
|
||||
IDS=(); LINES=(); INFOS=()
|
||||
while IFS=$'\t' read -r _id _line _info; do
|
||||
IDS+=("${_id}"); LINES+=("${_line}"); INFOS+=("${_info}")
|
||||
done < "${BLOCKS}/index"
|
||||
|
||||
for ((n = 0; n < ${#IDS[@]}; n++)); do
|
||||
INFO="${INFOS[n]}"
|
||||
BODY="${BLOCKS}/${IDS[n]}"
|
||||
WHERE="${DOC}:${LINES[n]}"
|
||||
KIND="${INFO%% *}"
|
||||
|
||||
# An `output` block belongs to the block above it, which claimed it on
|
||||
# the way past. One that nothing claimed is compared against nothing at
|
||||
# all -- indistinguishable from a passing test, and the exact failure
|
||||
# this harness exists to rule out. A *skipped* block still claims its
|
||||
# output: `norun` is a decision, not an accident.
|
||||
if [ "${KIND}" = "output" ]; then
|
||||
if [ "${CLAIMED}" -eq 0 ]; then
|
||||
fail "${WHERE}" "an output block with nothing runnable above it"
|
||||
fi
|
||||
CLAIMED=0
|
||||
continue
|
||||
fi
|
||||
CLAIMED=0
|
||||
|
||||
EXPECTED=""
|
||||
if [ $((n + 1)) -lt ${#IDS[@]} ] && [ "${INFOS[n + 1]%% *}" = "output" ]; then
|
||||
case "${KIND}" in
|
||||
sh)
|
||||
EXPECTED="${BLOCKS}/${IDS[n + 1]}"
|
||||
CLAIMED=1
|
||||
;;
|
||||
c)
|
||||
if [ -n "$(attr run "${INFO}")" ]; then
|
||||
EXPECTED="${BLOCKS}/${IDS[n + 1]}"
|
||||
CLAIMED=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if has_word norun "${INFO}"; then
|
||||
SKIPPED[norun]=$((SKIPPED[norun] + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# A `screenshot=NAME` block is the source of docs/images/NAME.png,
|
||||
# generated by tools/docs_screenshots.sh. The image is tracked, because a
|
||||
# reader on the forge has no build tree -- so nothing would otherwise
|
||||
# notice a figure that was tagged and never generated, and the chapter
|
||||
# would render a broken image. Whether the file is *of that listing* is
|
||||
# the docs_screenshots test's question, not this one's.
|
||||
SHOT="$(attr screenshot "${INFO}")"
|
||||
if [ -n "${SHOT}" ]; then
|
||||
if [ -r "docs/images/${SHOT}.png" ]; then
|
||||
RAN[screenshot]=$((RAN[screenshot] + 1))
|
||||
else
|
||||
fail "${WHERE}" "screenshot=${SHOT} has no docs/images/${SHOT}.png; run \`cmake --build build --target docs_screenshots\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${KIND}" in
|
||||
c)
|
||||
EXCERPT="$(attr excerpt "${INFO}")"
|
||||
if [ -n "${EXCERPT}" ]; then
|
||||
run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}"
|
||||
elif [ -n "$(attr run "${INFO}")" ]; then
|
||||
run_c_run "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}" "${EXPECTED}"
|
||||
elif [ -n "${SHOT}" ]; then
|
||||
# Compiled here as well as rendered there, so a figure's listing
|
||||
# is held to the same -Werror as every other snippet even in a
|
||||
# tree where the figures are not being regenerated. wrap= is
|
||||
# implied: a frame body is not a translation unit.
|
||||
run_c "${WHERE}" "${BODY}" "wrap=akglframe" "${DOC}" "${LINES[n]}"
|
||||
else
|
||||
run_c "${WHERE}" "${BODY}" "${INFO}" "${DOC}" "${LINES[n]}"
|
||||
fi
|
||||
;;
|
||||
json)
|
||||
run_json "${WHERE}" "${BODY}" "${INFO}"
|
||||
;;
|
||||
sh)
|
||||
run_sh "${WHERE}" "${BODY}" "${INFO}" "${EXPECTED}"
|
||||
;;
|
||||
cmake)
|
||||
SKIPPED[cmake]=$((SKIPPED[cmake] + 1))
|
||||
;;
|
||||
# A block diagram, or a stack trace captured from a real run. There is no
|
||||
# executable claim in either, so the tag exists to say that out loud
|
||||
# rather than to let one sit here untagged and unnoticed.
|
||||
text)
|
||||
SKIPPED[text]=$((SKIPPED[text] + 1))
|
||||
;;
|
||||
norun)
|
||||
SKIPPED[norun]=$((SKIPPED[norun] + 1))
|
||||
;;
|
||||
"")
|
||||
fail "${WHERE}" "fenced block with no info string; tag it (see docs/MAINTENANCE.md)"
|
||||
;;
|
||||
*)
|
||||
fail "${WHERE}" "unknown info string \"${INFO}\" (see docs/MAINTENANCE.md)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
# The count is part of the result, not decoration. A harness that passes because
|
||||
# a chapter stopped matching the extractor looks exactly like a harness that
|
||||
# passes because the documentation is correct, and this is what tells them apart.
|
||||
# A rising `norun` is the harness being talked out of its job.
|
||||
echo "ran: ${RAN[c]} c snippets, ${RAN[run]} linked programs, ${RAN[output]} output comparisons, ${RAN[excerpt]} excerpts, ${RAN[json]} json blocks, ${RAN[sh]} shell blocks, ${RAN[screenshot]} figures"
|
||||
echo "skipped: ${SKIPPED[norun]} norun, ${SKIPPED[cmake]} cmake, ${SKIPPED[text]} text, ${SKIPPED[nocc]} c (no compiler flags given), ${SKIPPED[nold]} run (no linker flags given), ${SKIPPED[nojson]} json (no validator given)"
|
||||
|
||||
if [ "${FAILURES}" -eq 0 ]; then
|
||||
echo "PASS: every documented example does what the documentation says"
|
||||
fi
|
||||
exit "${FAILURES}"
|
||||
67
tests/docs_preludes/akglapp.post
Normal file
67
tests/docs_preludes/akglapp.post
Normal file
@@ -0,0 +1,67 @@
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set in HANDLE_DEFAULT and read after FINISH. Returning from inside a HANDLE
|
||||
* block leaves before RELEASE_ERROR and leaks the context's pool slot; AGENTS.md
|
||||
* spells that out, and a documentation example is a bad place to teach it wrong.
|
||||
*/
|
||||
static int docs_failed = 0;
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/*
|
||||
* Set here as well as in the environment, so the program answers the same
|
||||
* whether the harness ran it or a reader did.
|
||||
*/
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
FAIL_ZERO_BREAK(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s", SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(
|
||||
"net/aklabs/libakgl/docs", 320, 240, 0,
|
||||
&akgl_window, &akgl_renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s", SDL_GetError());
|
||||
|
||||
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
|
||||
akgl_camera = &akgl_default_camera;
|
||||
akgl_camera->x = 0.0f;
|
||||
akgl_camera->y = 0.0f;
|
||||
akgl_camera->w = 320.0f;
|
||||
akgl_camera->h = 240.0f;
|
||||
|
||||
CATCH(errctx, docs_example());
|
||||
} CLEANUP {
|
||||
if ( akgl_window != NULL ) {
|
||||
SDL_DestroyWindow(akgl_window);
|
||||
akgl_window = NULL;
|
||||
}
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the documented example failed");
|
||||
docs_failed = 1;
|
||||
/*
|
||||
* FINISH_NORETURN rather than FINISH: FINISH expands a
|
||||
* `return __err_context` that an int-returning function cannot compile,
|
||||
* even where the branch is unreachable. src/main.c in akbasic and
|
||||
* tools/docs_screenshot.c do the same and say so.
|
||||
*/
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
return docs_failed;
|
||||
}
|
||||
24
tests/docs_preludes/akglapp.pre
Normal file
24
tests/docs_preludes/akglapp.pre
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* akglapp -- a complete program, brought up headless and run.
|
||||
*
|
||||
* This is the prelude behind `c run=akglapp`, and it is the reason the harness
|
||||
* links at all. -fsyntax-only proves a call typechecks; it does not prove the
|
||||
* startup order works, that the sprite loads, or that an ATTEMPT block gives
|
||||
* back what it took. A run= block proves those, and an `output` block below it
|
||||
* pins what the reader will actually see.
|
||||
*
|
||||
* The block supplies the body. Anything it prints on stdout is the example's
|
||||
* output; libakgl's own registry chatter goes to stderr and is not compared.
|
||||
*
|
||||
* The startup here is the same sequence tests/sprite.c and tools/docs_screenshot.c
|
||||
* use, and it is the sequence chapter 07 documents: error registry, renderer
|
||||
* backend, SDL, window, bind, heap, registries, camera. A host that owns its own
|
||||
* window does exactly this rather than calling akgl_game_init().
|
||||
*/
|
||||
#include <docs_prelude.h>
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *docs_example(void);
|
||||
|
||||
akerr_ErrorContext *docs_example(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
2
tests/docs_preludes/akglbody.post
Normal file
2
tests/docs_preludes/akglbody.post
Normal file
@@ -0,0 +1,2 @@
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
22
tests/docs_preludes/akglbody.pre
Normal file
22
tests/docs_preludes/akglbody.pre
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* akglbody -- the inside of a function that returns an error context.
|
||||
*
|
||||
* The overwhelmingly common shape of a libakgl example: a few locals, an
|
||||
* ATTEMPT block, and calls that are checked. Written out in full, every one of
|
||||
* those blocks would open with the same four lines and close with the same two,
|
||||
* and a reader would learn the protocol six times instead of the call once.
|
||||
*
|
||||
* The block supplies everything between `PREPARE_ERROR` and the closing brace.
|
||||
* `errctx` is the local error context, per the house parameter names.
|
||||
*
|
||||
* Nothing here declares an akgl_ function. A prelude that did would let an
|
||||
* example calling it with the wrong arguments compile, which is the one thing
|
||||
* this whole harness exists to catch.
|
||||
*/
|
||||
#include <docs_prelude.h>
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *docs_example(void);
|
||||
|
||||
akerr_ErrorContext *docs_example(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
11
tests/docs_preludes/akglfile.pre
Normal file
11
tests/docs_preludes/akglfile.pre
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* akglfile -- file scope.
|
||||
*
|
||||
* The block is a run of top-level declarations: a movementlogicfunc, a backend
|
||||
* vtable, a static table. All this prelude supplies is the include set, so the
|
||||
* chapter does not have to spend six lines on `#include` before showing the one
|
||||
* function it is about.
|
||||
*
|
||||
* There is no akglfile.post. Nothing has to be closed.
|
||||
*/
|
||||
#include <docs_prelude.h>
|
||||
2
tests/docs_preludes/akglframe.post
Normal file
2
tests/docs_preludes/akglframe.post
Normal file
@@ -0,0 +1,2 @@
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
17
tests/docs_preludes/akglframe.pre
Normal file
17
tests/docs_preludes/akglframe.pre
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* akglframe -- one frame of drawing, for a figure.
|
||||
*
|
||||
* `c screenshot=NAME` blocks are wrapped in this and linked against
|
||||
* tools/docs_screenshot.c, which owns main(), brings libakgl up against an
|
||||
* offscreen target of the stated size, calls docs_frame(), reads the target
|
||||
* back and writes docs/images/NAME.png.
|
||||
*
|
||||
* The block draws and returns. It must print nothing: tools/docs_screenshots.sh
|
||||
* fails a figure whose program wrote to stdout, because an image of a blank
|
||||
* screen is worse than no image at all.
|
||||
*/
|
||||
#include <docs_prelude.h>
|
||||
|
||||
akerr_ErrorContext *docs_frame(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
64
tests/docs_preludes/docs_prelude.h
Normal file
64
tests/docs_preludes/docs_prelude.h
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file docs_prelude.h
|
||||
* @brief The include set every `c wrap=`, `c run=` and `c screenshot=` block gets.
|
||||
*
|
||||
* The four preludes in this directory all begin with this file, so the list of
|
||||
* headers a wrapped example can rely on lives in exactly one place. It is not
|
||||
* reachable from a `c` block with no `wrap=`: an unwrapped block is a whole
|
||||
* translation unit and has to show its own `#include` lines, which is usually
|
||||
* what a chapter wants a reader to see.
|
||||
*
|
||||
* Every public header, deliberately. The `headers` suite already proves each one
|
||||
* is self-contained, so there is no ordering to get right, and an example that
|
||||
* calls one subsystem while illustrating another does not have to grow an
|
||||
* include line that is noise in the chapter.
|
||||
*
|
||||
* `docs_frame()` is declared here rather than in tools/docs_screenshot.c so the
|
||||
* host and the akglframe prelude cannot drift apart on the signature.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_DOCS_PRELUDE_H_
|
||||
#define _AKGL_DOCS_PRELUDE_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/assets.h>
|
||||
#include <akgl/audio.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/**
|
||||
* @brief One frame of drawing, supplied by a `c screenshot=NAME` block.
|
||||
*
|
||||
* tools/docs_screenshot.c brings libakgl up against an offscreen target, calls
|
||||
* this, and writes what it drew to `docs/images/NAME.png`. The body is the
|
||||
* chapter's own listing, wrapped by tests/docs_preludes/akglframe.pre.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *docs_frame(void);
|
||||
|
||||
#endif // _AKGL_DOCS_PRELUDE_H_
|
||||
68
tests/docs_setups/character.sh
Executable file
68
tests/docs_setups/character.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stage the sprites a `json kind=character` block names.
|
||||
#
|
||||
# akgl_character_load_json resolves every `sprite` field against
|
||||
# AKGL_REGISTRY_SPRITE and fails on a name that is not there, so a chapter that
|
||||
# shows a character file on its own is not runnable on its own. Rather than make
|
||||
# the chapter print two sprite files it is not about, the sprites live here and
|
||||
# tools/docs_checkjson.c loads everything in ./sprites through
|
||||
# akgl_sprite_load_json before it touches the character.
|
||||
#
|
||||
# **The two sprite names are a contract with docs/11-characters.md**, which names
|
||||
# `hero standing left` and `hero walking left` in its `sprite_mappings`. Changing
|
||||
# either name here breaks that chapter, so change both together.
|
||||
#
|
||||
# The sheet is staged inside ./sprites because a sprite file's `spritesheet`
|
||||
# path is resolved relative to the sprite file's own directory.
|
||||
#
|
||||
# $1 is the repository root. The caller runs this with the sandbox as the
|
||||
# working directory.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="${1:-}"
|
||||
if [ -z "${ROOT}" ]; then
|
||||
echo "usage: character.sh <repository-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p sprites || exit 1
|
||||
cp "${ROOT}/tests/assets/spritesheet.png" sprites/spritesheet.png || exit 1
|
||||
|
||||
# 576x384 of 48x48 frames: a 12x8 grid, so frames 0-95 exist. Two frames each,
|
||||
# which is enough for the animation fields to mean something without making the
|
||||
# fixture a thing anybody has to study.
|
||||
cat > sprites/hero_standing_left.json <<'JSON'
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "hero standing left",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 200,
|
||||
"loop": true,
|
||||
"loopReverse": false,
|
||||
"frames": [0]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > sprites/hero_walking_left.json <<'JSON'
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "hero walking left",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [0, 1, 2]
|
||||
}
|
||||
JSON
|
||||
24
tests/docs_setups/spritesheet.sh
Executable file
24
tests/docs_setups/spritesheet.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stage a spritesheet image in the sandbox, as `spritesheet.png`.
|
||||
#
|
||||
# A `json kind=sprite` block names its sheet, and the loader really opens it --
|
||||
# that is the whole point of running the documented format through the real
|
||||
# loader. The chapter should not have to show a base64 PNG to get there, so the
|
||||
# fixture comes from here instead.
|
||||
#
|
||||
# 576x384, a 12x8 grid of 48x48 frames. tests/assets/spritesheet.png is the same
|
||||
# image the sprite suite uses.
|
||||
#
|
||||
# $1 is the repository root. The caller runs this with the sandbox as the
|
||||
# working directory.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="${1:-}"
|
||||
if [ -z "${ROOT}" ]; then
|
||||
echo "usage: spritesheet.sh <repository-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cp "${ROOT}/tests/assets/spritesheet.png" spritesheet.png || exit 1
|
||||
85
tests/docs_setups/tilemap.sh
Executable file
85
tests/docs_setups/tilemap.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stage everything a `json kind=tilemap` block needs around it.
|
||||
#
|
||||
# A tilemap is the least self-contained of the four formats. Loading one really
|
||||
# opens the tileset image, and an actor object really resolves its `character`
|
||||
# property against AKGL_REGISTRY_CHARACTER -- which in turn needs the character's
|
||||
# sprites registered. So this stages three things, and
|
||||
# tools/docs_checkjson.c's `tilemap` row loads ./sprites and then ./characters
|
||||
# before the map itself.
|
||||
#
|
||||
# **The tileset image is staged as `assets/tileset.png`**, and a chapter's map
|
||||
# has to name that path. It is a copy of the sprite sheet, and that is fine: the
|
||||
# loader does not check the image against the tileset's declared geometry, it
|
||||
# only has to be a readable PNG.
|
||||
#
|
||||
# The obvious alternative was `assets/World_A1.png`, which is what
|
||||
# tests/assets/testmap.tmj names. That file carries the default filename of
|
||||
# RPG Maker's bundled art and ships here with no license file beside it, which
|
||||
# is a finding this project is recording rather than spreading into the manual.
|
||||
# A tutorial a reader copies must not inherit an obligation nobody has checked.
|
||||
#
|
||||
# **The character is named `testcharacter`**, which is a contract with any
|
||||
# chapter whose map has an actor object. Change it here and change the chapter.
|
||||
#
|
||||
# Embedded tilesets only. `"source"` appears nowhere in src/tilemap.c;
|
||||
# akgl_tilemap_load_tilesets_each reads `columns`, `firstgid`, `tilecount` and
|
||||
# `image` straight off each element of the map's own `tilesets` array. An
|
||||
# external tileset reference -- the form Tiled writes by default -- does not
|
||||
# load. Every object in an object group also needs a `type` string, plain
|
||||
# rectangles included.
|
||||
#
|
||||
# $1 is the repository root. The caller runs this with the sandbox as the
|
||||
# working directory.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="${1:-}"
|
||||
if [ -z "${ROOT}" ]; then
|
||||
echo "usage: tilemap.sh <repository-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p assets sprites characters || exit 1
|
||||
cp "${ROOT}/tests/assets/spritesheet.png" assets/tileset.png || exit 1
|
||||
cp "${ROOT}/tests/assets/spritesheet.png" sprites/spritesheet.png || exit 1
|
||||
|
||||
cat > sprites/testactor_standing.json <<'JSON'
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "testactor standing",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 200,
|
||||
"loop": true,
|
||||
"loopReverse": false,
|
||||
"frames": [0]
|
||||
}
|
||||
JSON
|
||||
|
||||
# AKGL_ACTOR_STATE_ALIVE is 1 << 4 and AKGL_ACTOR_STATE_FACE_DOWN is 1 << 0, so
|
||||
# this mapping is the state 17 a map's actor object usually carries.
|
||||
cat > characters/testcharacter.json <<'JSON'
|
||||
{
|
||||
"name": "testcharacter",
|
||||
"speedtime": 100,
|
||||
"speed_x": 0.20,
|
||||
"speed_y": 0.20,
|
||||
"acceleration_x": 0.20,
|
||||
"acceleration_y": 0.20,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_DOWN"
|
||||
],
|
||||
"sprite": "testactor standing"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
Reference in New Issue
Block a user