diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index aa7382d..840321a 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -119,3 +119,18 @@ jobs: run: | cargo build --release python3 tests/readme-examples.py + + # Every binding is the same engine reached by a different door, so the + # answers have to be identical, and the only way to know is to ask them + # all the same question. Twelve days chosen where they could plausibly + # differ, twenty-two fields each, against what the command line says. + # + # Chromium is installed for this: the web build is the one artifact whose + # real home is somewhere Node cannot stand in for, and loading it with + # Node's bytes proves the glue, not the fetch. + - name: Every binding agrees + run: | + apt-get install -y --no-install-recommends chromium + version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock) + export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH" + tests/parity.sh diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0762c1e..3bec259 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -32,8 +32,20 @@ jobs: echo "tag=$tag crate=$crate" [ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; } + # What CI runs on every push, run again here. A release is the one build + # nobody gets to re-run after the fact: PyPI will not reuse a version and + # neither will npm, so the gate belongs before the upload rather than + # only on the branch that led to it. - name: Tests - run: cargo test + run: | + rustup component add clippy + cargo clippy --all-targets -- -D warnings + cargo clippy --features python --all-targets -- -D warnings + cargo clippy --features capi --all-targets -- -D warnings + rustup target add wasm32-unknown-unknown + cargo clippy --lib --features wasm --target wasm32-unknown-unknown -- -D warnings + cargo test + cargo test --features capi - name: Build wheels and sdist run: | @@ -76,6 +88,17 @@ jobs: npm/build.sh node npm/test.mjs + # The whole point of a release is that every door leads to the same + # engine. Checked here against what is about to be uploaded, and again + # after the fact with --published against what actually was. + - name: Every binding agrees + run: | + apt-get install -y --no-install-recommends chromium luajit + version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock) + export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH" + tests/parity.sh + python3 tests/readme-examples.py + - name: Publish env: # A project-scoped token, not the account-wide one. PyPI's trusted diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c0932..aef57a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out. **Calendar** entries change what a day reports, whatever the version number does. +## Unreleased + +### Fixed + +- The npm package's files are reachable through its exports map. A bundler + asked for `typikon/web/typikon_bg.wasm` was told no such path is exported, + so a web app that wants to hand the WebAssembly to `init()` as an asset had + to reach around the package into `node_modules`. The documented call, `await + init()` with no argument, was never affected. + +### Added + +- `tests/parity.sh`: every binding asked the same twelve days and compared + field by field against the command line. The days are the ones where the + bindings could plausibly differ, and the surfaces are the command line, the + Python wheel, Node by import and by require, TypeScript under `--strict`, a + real browser, Deno, Bun, the C ABI and LuaJIT over it. A surface whose + toolchain is absent is named as skipped rather than silently counted. + `--published` runs the same checks against PyPI and npm instead of the + checkout. + +### Changed + +- A release runs what a push runs. The release workflow had been gated on + `cargo test` alone, so the C ABI, the Lua binding, the Python parity check + and the README examples were checked on the branch that led to a release but + not on the release itself. A version cannot be reused on either registry, so + the gate belongs before the upload. + ## 1.1.0 New ways in, and no change to any day: nothing here moves a feast, a fast diff --git a/README.md b/README.md index 78ac705..b333765 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,17 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above. ![The menologion widget](https://git.pumpin.online/funkpower/menologion/raw/branch/main/docs/nativity-fast.png) +## Every binding agrees + +There is one engine and several doors into it, so the doors have to agree. +`tests/parity.sh` asks each one for the same twelve days, chosen where they +could plausibly differ, and compares all twenty-two fields against what the +command line says: the Python wheel, Node by import and by require, TypeScript +under `--strict`, a real browser, Deno, Bun, the C ABI and LuaJIT over it. It +runs on every push and again before a release is uploaded, and +`tests/parity.sh --published` runs it against what is on PyPI and npm rather +than against the checkout. + ## Releasing `main` is always releasable; a `vX.Y.Z` tag on it builds the wheels and publishes diff --git a/docs/releasing.md b/docs/releasing.md index 9d09089..25f3f52 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -166,6 +166,25 @@ $ /tmp/v/bin/python -c 'import typikon, datetime; print(typikon.today())' Installing from the sdist with `--no-binary` is the one that catches a missing file, because it builds the crate from exactly what PyPI would hand someone. +### Every binding agrees + +`tests/parity.sh` asks every binding for the same twelve days and compares all +twenty-two fields against what the command line says. It runs in CI on every +push and again in the release job before anything is uploaded. + +```console +$ tests/parity.sh # against this checkout +$ tests/parity.sh --published # against what is on PyPI and npm +``` + +Run the second one after a release. It is the only check that exercises the +artefacts people will actually install rather than the ones the build produced. + +A surface whose toolchain is missing is skipped by name, and the script refuses +to report a pass if fewer than three ran, so a container with nothing installed +cannot look like a green run. Everything it wants: `python3` with `venv`, `node` +and `npm`, `cc`, `luajit`, `chromium`, and optionally `deno` and `bun`. + For the npm package: ```console diff --git a/npm/package.json.in b/npm/package.json.in index 7108a62..4f16d07 100644 --- a/npm/package.json.in +++ b/npm/package.json.in @@ -38,6 +38,8 @@ "types": "./web/typikon.d.ts", "default": "./web/typikon.js" }, + "./node/*": "./node/*", + "./web/*": "./web/*", "./package.json": "./package.json" }, "files": [ diff --git a/tests/parity.sh b/tests/parity.sh new file mode 100755 index 0000000..c2339b0 --- /dev/null +++ b/tests/parity.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Every binding, asked the same twelve days, compared field by field against +# what the command line says. +# +# tests/parity.sh # against what is in this checkout +# tests/parity.sh --published # against what is on PyPI and npm +# +# A surface whose toolchain is missing is skipped by name. A surface that runs +# and disagrees fails the script. tests/parity/README.md says why these twelve. +set -uo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" +P=tests/parity +published=0 +[ "${1:-}" = "--published" ] && published=1 + +work="$(mktemp -d)" +server="" +# By pid. A job spec does not survive a subshell, and a web server left holding +# the script's stdout is a suite that hangs rather than fails. +cleanup() { + [ -n "$server" ] && kill "$server" 2>/dev/null && wait "$server" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +pass=0 fail=0 skipped=() +note() { printf '\n\033[1m%s\033[0m\n' "$1"; } +skip() { skipped+=("$1: $2"); printf ' skip %s (%s)\n' "$1" "$2"; } +verify() { # name, path to that surface's json + if python3 "$P/compare.py" "$1" "$work/oracle.json" "$2"; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + fi +} + +# Neither a config file nor a fetched menaion may reach any surface, or this +# measures the machine rather than the code. +# +# Empty directories, not missing ones. Pointing these at a path that does not +# exist isolates typikon and breaks everything else that keeps state under +# them: a browser cannot build a profile and its crash handler will not start +# without somewhere to write. +export XDG_CONFIG_HOME="$work/xdg/config" XDG_DATA_HOME="$work/xdg/data" +mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" + +# ── the oracle ──────────────────────────────────────────────────────────── +note "oracle" +cargo build --quiet --release || exit 1 +python3 "$P/oracle.py" ./target/release/typikon "$P/probes.json" "$work/oracle.json" || exit 1 + +# The C probe carries its own copy of the list, because a binding whose point +# is to spare its callers a JSON parser should not need one to be tested. +python3 - "$P/probes.json" "$P/probe.c" <<'PY' || exit 1 +import json, re, sys +probes = json.load(open(sys.argv[1])) +src = open(sys.argv[2]).read() +found = re.findall(r"\{(\d+),(\d+),(\d+),\"([a-z-]+)\",(-?\d+)\}", src.replace(" ", "")) +mine = [(f"{int(y):04d}-{int(m):02d}-{int(d):02d}", j, t == "1") for y, m, d, j, t in found] +want = [(d, j, bool(t)) for d, j, t in probes] +if mine != want: + sys.exit(f"tests/parity/probe.c has drifted from probes.json:\n c {mine}\n json {want}") +PY + +# ── python ──────────────────────────────────────────────────────────────── +note "python" +py="$work/venv/bin/python" +if ! python3 -m venv "$work/venv" >"$work/py.log" 2>&1; then + skip python "no usable python3 -m venv" +elif [ "$published" = 1 ] && ! "$work/venv/bin/pip" install --quiet typikon >>"$work/py.log" 2>&1; then + skip python "typikon would not install from PyPI" +elif [ "$published" = 0 ] && ! { + "$work/venv/bin/pip" install --quiet maturin >>"$work/py.log" 2>&1 && + "$work/venv/bin/maturin" build --release --out "$work/wheels" >>"$work/py.log" 2>&1 && + "$work/venv/bin/pip" install --quiet --no-index --find-links "$work/wheels" typikon \ + >>"$work/py.log" 2>&1 + }; then + echo " FAIL python: the wheel would not build or install" + tail -12 "$work/py.log" | sed 's/^/ /' + fail=$((fail + 1)) +else + "$py" "$P/probe.py" "$P/probes.json" "$work/python.json" \ + && verify "python" "$work/python.json" + # The stubs are part of what is published, so they are part of what is + # checked. --strict, because a stub that only passes loosely is not one. + if "$work/venv/bin/pip" install --quiet mypy >>"$work/py.log" 2>&1; then + if "$py" -m mypy --strict "$P/probe.py" >"$work/mypy.log" 2>&1; then + echo " ok python: mypy --strict" + else + echo " FAIL python: mypy --strict" + tail -8 "$work/mypy.log" | sed 's/^/ /' + fail=$((fail + 1)) + fi + else + skip "python, mypy" "mypy would not install" + fi +fi + +# ── javascript ──────────────────────────────────────────────────────────── +note "javascript" +if ! command -v node >/dev/null; then + skip javascript "no node" +else + js="$work/js" + mkdir -p "$js" + printf '{"name":"parity","private":true,"type":"module"}\n' > "$js/package.json" + cp "$P/probes.json" "$P/shape.mjs" "$P"/probe.{mjs,cjs,ts} "$js/" + mkdir -p "$js/web" && cp "$P"/web/{entry.mjs,index.html,extract.py} "$js/web/" + + if [ "$published" = 1 ]; then + (cd "$js" && npm install --silent typikon >/dev/null 2>&1) + else + npm/build.sh >/dev/null 2>&1 && (cd "$js" && npm install --silent "$root/npm/dist" >/dev/null 2>&1) + fi + + if [ ! -d "$js/node_modules/typikon" ]; then + skip javascript "the package would not build or install" + else + node "$js/probe.mjs" "$js/probes.json" "$work/node-esm.json" \ + && verify "node, import" "$work/node-esm.json" + node "$js/probe.cjs" "$js/probes.json" "$work/node-cjs.json" \ + && verify "node, require" "$work/node-cjs.json" + + # TypeScript: compiled under --strict, then run. + if (cd "$js" && npm install --silent --no-save typescript@5 @types/node >/dev/null 2>&1); then + if (cd "$js" && ./node_modules/.bin/tsc --strict --target es2022 --module nodenext \ + --moduleResolution nodenext --types node --outDir out probe.ts >/dev/null); then + node "$js/out/probe.js" "$js/probes.json" "$work/typescript.json" \ + && verify "typescript, tsc --strict then run" "$work/typescript.json" + else + echo " FAIL typescript: tsc --strict rejected the published types"; fail=$((fail + 1)) + fi + else + skip typescript "typescript would not install" + fi + + # The browser, which is the only place the web build is really exercised: + # a real fetch of the wasm, and no Node to fall back on. + chrome="" + for c in chromium chromium-browser google-chrome google-chrome-stable; do + command -v "$c" >/dev/null && { chrome="$c"; break; } + done + if [ -z "$chrome" ]; then + skip browser "no chromium" + elif ! (cd "$js" && npm install --silent --no-save esbuild >/dev/null 2>&1); then + skip browser "esbuild would not install" + else + if ! (cd "$js" && ./node_modules/.bin/esbuild web/entry.mjs --bundle \ + --format=esm --platform=browser --loader:.wasm=file \ + --outdir=served --log-level=error); then + echo " FAIL browser: the bundle would not build"; fail=$((fail + 1)) + else + cp "$js/web/index.html" "$js/probes.json" "$js/served/" + # A port the kernel picks, so a leftover server from a previous run + # cannot quietly serve a stale bundle to this one. + port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()') + python3 -m http.server "$port" --bind 127.0.0.1 \ + --directory "$js/served" >/dev/null 2>&1 & + server=$! + for _ in $(seq 40); do + curl -sf -o /dev/null "http://127.0.0.1:$port/index.html" && break + sleep 0.25 + done + # --user-data-dir because this script points XDG_CONFIG_HOME at + # nothing, to keep a real config file away from the surfaces under + # test, and a browser given nowhere to put a profile does not start. + # --disable-dev-shm-usage because a container's /dev/shm is too small. + # Bounded: a browser that never paints must fail the suite, not hang it. + timeout 90 "$chrome" --headless --disable-gpu --no-sandbox \ + --no-first-run --disable-dev-shm-usage \ + --user-data-dir="$work/chrome" \ + --virtual-time-budget=20000 --dump-dom "http://127.0.0.1:$port/index.html" \ + >"$work/dom.html" 2>"$work/chrome.log" + kill "$server" 2>/dev/null; wait "$server" 2>/dev/null; server="" + if python3 "$P/web/extract.py" "$work/dom.html" "$work/browser.json"; then + verify "browser, bare import of the web build" "$work/browser.json" + else + echo " FAIL browser" + tail -5 "$work/chrome.log" 2>/dev/null | sed 's/^/ /' + fail=$((fail + 1)) + fi + fi + fi + + for rt in deno bun; do + command -v "$rt" >/dev/null || { skip "$rt" "not installed"; continue; } + case "$rt" in + deno) (cd "$js" && deno run --quiet --allow-read --allow-write --allow-env \ + probe.mjs probes.json "$work/deno.json") ;; + bun) (cd "$js" && bun run probe.mjs probes.json "$work/bun.json") ;; + esac && verify "$rt" "$work/$rt.json" + done + fi +fi + +# ── the C ABI, and Lua over it ──────────────────────────────────────────── +note "C and Lua" +if ! command -v cc >/dev/null; then + skip "C ABI" "no C compiler" +elif ! cargo build --quiet --release --features capi; then + echo " FAIL C ABI: the library would not build"; fail=$((fail + 1)) +else + if cc -Wall -Wextra -Werror -Iinclude "$P/probe.c" \ + -Ltarget/release -ltypikon -o "$work/c-probe" 2>"$work/cc.log"; then + LD_LIBRARY_PATH=target/release "$work/c-probe" "$work/c.json" \ + && verify "C ABI" "$work/c.json" + else + echo " FAIL C ABI: the probe would not compile"; sed 's/^/ /' "$work/cc.log" + fail=$((fail + 1)) + fi + # The worked example has to keep working too. + if cc -Wall -Wextra -Werror -Iinclude examples/c/demo.c \ + -Ltarget/release -ltypikon -o "$work/demo" 2>/dev/null \ + && LD_LIBRARY_PATH=target/release "$work/demo" rocor >/dev/null; then + echo " ok C ABI: examples/c/demo.c" + else + echo " FAIL C ABI: examples/c/demo.c"; fail=$((fail + 1)) + fi + + if ! command -v luajit >/dev/null; then + skip luajit "not installed" + else + TYPIKON_LIB=target/release/libtypikon.so TYPIKON_LUA=examples/lua \ + luajit "$P/probe.lua" "$work/lua.json" && verify "luajit" "$work/lua.json" + TYPIKON_LIB=target/release/libtypikon.so \ + luajit examples/lua/statusline.lua rocor >/dev/null \ + && echo " ok luajit: examples/lua/statusline.lua" \ + || { echo " FAIL luajit: examples/lua/statusline.lua"; fail=$((fail + 1)); } + fi +fi + +# ── verdict ─────────────────────────────────────────────────────────────── +note "verdict" +printf ' %d surfaces agree with the command line, %d disagree\n' "$pass" "$fail" +for s in "${skipped[@]:-}"; do [ -n "$s" ] && printf ' not run: %s\n' "$s"; done + +# A run that skipped everything must not read as a pass. +if [ "$pass" -lt 3 ]; then + echo " too few surfaces ran to call this a check" + exit 1 +fi +exit $(( fail > 0 )) diff --git a/tests/parity/README.md b/tests/parity/README.md new file mode 100644 index 0000000..a4cebc5 --- /dev/null +++ b/tests/parity/README.md @@ -0,0 +1,14 @@ +# Parity + +Every binding answers the same question, so every binding has to give the same +answer. These probes ask each one for the same twelve days and compare all +twenty-two fields against what the command line says. + +The probes are chosen for the places the bindings could plausibly disagree: +Pascha itself, the Nativity on both calendars, a Wednesday of the Great Fast +under Byzantine Catholic law and again at the Typikon, a weekday of the +Nativity Fast where Greek and Slavic usage part, the one Orthodox church on the +Gregorian Paschalion, and a day with no feast at all. + +Run them with `tests/parity.sh`. Surfaces whose toolchain is absent are skipped +by name rather than silently passed. diff --git a/tests/parity/compare.py b/tests/parity/compare.py new file mode 100644 index 0000000..bc5f362 --- /dev/null +++ b/tests/parity/compare.py @@ -0,0 +1,29 @@ +"""One surface's answers against the oracle's. + +A surface need not expose every field; it must not disagree about one it does. +""" +import json +import sys + +name, oracle_path, got_path = sys.argv[1:4] +oracle = json.load(open(oracle_path)) +got = json.load(open(got_path)) + +bad = [] +for key in sorted(set(oracle) - set(got)): + bad.append(f"missing probe {key}") +for key in sorted(set(got) - set(oracle)): + bad.append(f"unexpected probe {key}") +for key in sorted(set(oracle) & set(got)): + for field, expected in sorted(oracle[key].items()): + if field in got[key] and got[key][field] != expected: + bad.append(f"{key} {field}: {got[key][field]!r} != {expected!r}") + +fields = sorted({f for v in got.values() for f in v}) +for line in bad[:20]: + print(f" {line}") +if len(bad) > 20: + print(f" ... and {len(bad) - 20} more") +print(f" {'ok ' if not bad else 'FAIL'} {name}: " + f"{len(got)} probes, {len(fields)} fields, {len(bad)} differences") +sys.exit(1 if bad else 0) diff --git a/tests/parity/oracle.py b/tests/parity/oracle.py new file mode 100644 index 0000000..0691f77 --- /dev/null +++ b/tests/parity/oracle.py @@ -0,0 +1,35 @@ +"""The command line's answer, which every other surface is measured against.""" +import json +import os +import subprocess +import sys + +FIELDS = [ + "date", "weekday", "jurisdiction", "calendar", "churchDate", "season", + "cycle", "feast", "rank", "sign", "tradition", "title", "commemorations", + "fast", "fastLabel", "emoji", "reason", "obligation", "binds", "pascha", + "daysToPascha", "julianOffset", +] + +cli, probes_path, out_path = sys.argv[1:4] + +# Neither a config file nor a fetched menaion may reach any surface, or the +# comparison measures the machine rather than the code. The caller has already +# pointed these at empty directories; this only refuses to run without them, +# because an oracle that quietly read the developer's own jurisdiction would +# make every surface agree about the wrong thing. +env = dict(os.environ) +for var in ("XDG_CONFIG_HOME", "XDG_DATA_HOME"): + if not env.get(var): + sys.exit(f"{var} must be set to an empty directory before reckoning the oracle") + +out = {} +for date, jurisdiction, typikon in json.load(open(probes_path)): + args = [cli, "--date", date, "--jurisdiction", jurisdiction, "--json", + "--typikon" if typikon else "--no-typikon"] + day = json.loads(subprocess.run(args, capture_output=True, text=True, + env=env, check=True).stdout) + out[f"{date}|{jurisdiction}|{int(typikon)}"] = {k: day[k] for k in FIELDS} + +json.dump(out, open(out_path, "w"), ensure_ascii=False) +print(f"oracle: {len(out)} probes, {len(FIELDS)} fields") diff --git a/tests/parity/probe.c b/tests/parity/probe.c new file mode 100644 index 0000000..139ffa5 --- /dev/null +++ b/tests/parity/probe.c @@ -0,0 +1,98 @@ +/* The C ABI, over the same probes, emitting the same JSON. + * + * The probe list is written out here rather than read from probes.json, + * because a JSON parser is exactly what this binding exists to spare its + * callers and it would be odd to need one to test it. tests/parity.sh checks + * the two lists have not drifted apart. + */ +#include +#include +#include +#include + +static void esc(FILE *f, const char *s) { + fputc('"', f); + for (; *s; s++) { + if (*s == '"' || *s == '\\') fprintf(f, "\\%c", *s); + else if ((unsigned char)*s < 0x20) fprintf(f, "\\u%04x", *s); + else fputc(*s, f); + } + fputc('"', f); +} + +static void field(FILE *f, const char *k, const char *v, int *first) { + if (!*first) fputc(',', f); + *first = 0; + esc(f, k); fputc(':', f); + if (v) esc(f, v); else fputs("null", f); +} + +int main(int argc, char **argv) { + (void)argc; + FILE *f = fopen(argv[1], "w"); + fputc('{', f); + int firstkey = 1; + + /* date, jurisdiction, discipline: the same twelve as probes.json. */ + struct { int y, m, d; const char *j; int t; } probes[] = { + {2026,4,12,"moscow",-1}, {2026,4,12,"goarch",-1}, {2026,1,7,"moscow",-1}, + {2025,12,25,"old",-1}, {2026,3,4,"ruthenian",0}, {2026,3,4,"ruthenian",1}, + {2025,12,9,"old",-1}, {2026,2,16,"oca",-1}, {2026,8,15,"antiochian",-1}, + {2026,9,18,"finnish",-1},{2027,5,2,"serbian",-1}, {2026,6,28,"constantinople",-1}, + }; + + for (size_t i = 0; i < sizeof probes / sizeof *probes; i++) { + int32_t jdn = typikon_jdn_from_ymd(probes[i].y, probes[i].m, probes[i].d); + int disc = probes[i].t < 0 ? 0 : probes[i].t; /* the CLI oracle passed --no-typikon */ + typikon_day *day = typikon_describe_full(jdn, probes[i].j, NULL, disc); + if (!day) { fprintf(stderr, "%s\n", typikon_last_error()); return 1; } + + char key[64]; + snprintf(key, sizeof key, "%04d-%02d-%02d|%s|%d", + probes[i].y, probes[i].m, probes[i].d, probes[i].j, + probes[i].t == 1 ? 1 : 0); + if (!firstkey) fputc(',', f); + firstkey = 0; + esc(f, key); fputs(":{", f); + int first = 1; + field(f, "date", typikon_day_iso(day), &first); + field(f, "weekday", typikon_day_weekday(day), &first); + field(f, "jurisdiction",typikon_day_jurisdiction(day), &first); + field(f, "calendar", typikon_day_calendar(day), &first); + field(f, "churchDate", typikon_day_church_date(day), &first); + field(f, "season", typikon_day_season(day), &first); + field(f, "cycle", typikon_day_cycle(day), &first); + field(f, "feast", typikon_day_feast(day), &first); + field(f, "rank", typikon_day_rank(day), &first); + field(f, "sign", typikon_day_sign(day), &first); + field(f, "tradition", typikon_day_tradition(day), &first); + field(f, "title", typikon_day_title(day), &first); + field(f, "fast", typikon_day_fast(day), &first); + field(f, "fastLabel", typikon_day_fast_label(day), &first); + field(f, "emoji", typikon_day_emoji(day), &first); + field(f, "reason", typikon_day_reason(day), &first); + field(f, "obligation", typikon_day_obligation(day), &first); + field(f, "binds", typikon_day_binds(day), &first); + + fputs(",\"commemorations\":[", f); + for (uint32_t c = 0; c < typikon_day_commemoration_count(day); c++) { + if (c) fputc(',', f); + esc(f, typikon_day_commemoration(day, c)); + } + fputc(']', f); + + int32_t p = typikon_day_pascha(day); + int32_t py; uint32_t pm, pd; + typikon_ymd_from_jdn(p, &py, &pm, &pd); + char iso[16]; + snprintf(iso, sizeof iso, "%04d-%02u-%02u", py, pm, pd); + fputs(",\"pascha\":", f); esc(f, iso); + fprintf(f, ",\"daysToPascha\":%d", typikon_day_days_to_pascha(day)); + fprintf(f, ",\"julianOffset\":%d", typikon_day_julian_offset(day)); + fputc('}', f); + typikon_day_free(day); + } + fputs("}\n", f); + fclose(f); + return 0; +} diff --git a/tests/parity/probe.cjs b/tests/parity/probe.cjs new file mode 100644 index 0000000..b60406a --- /dev/null +++ b/tests/parity/probe.cjs @@ -0,0 +1,20 @@ +// Node, by require. The other half of Node's world, and the half an exports +// map gets wrong. +const { readFileSync, writeFileSync } = require("node:fs"); +const { describeIso } = require("typikon"); + +const [probesPath, outPath] = process.argv.slice(2); +const out = {}; +for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8"))) { + const d = describeIso(date, { jurisdiction, typikon }); + out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = { + date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, + calendar: d.calendar, churchDate: d.churchDate, season: d.season, + cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign, + tradition: d.tradition, title: d.title, commemorations: d.commemorations, + fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, + obligation: d.obligation, binds: d.binds, pascha: d.pascha, + daysToPascha: d.daysToPascha, julianOffset: d.julianOffset, + }; +} +writeFileSync(outPath, JSON.stringify(out)); diff --git a/tests/parity/probe.lua b/tests/parity/probe.lua new file mode 100644 index 0000000..251980c --- /dev/null +++ b/tests/parity/probe.lua @@ -0,0 +1,59 @@ +-- The binding under test is the one in examples/lua. +package.path = (os.getenv("TYPIKON_LUA") or "examples/lua") .. "/?.lua;" .. package.path +local typikon = require("typikon") + +-- Minimal JSON writer: the binding hands back Lua tables, and the point is to +-- compare them with everything else, not to pull in a dependency. +local function esc(s) + return '"' .. s:gsub('[%c"\\]', function(c) + if c == '"' then return '\\"' elseif c == '\\' then return '\\\\' end + return string.format('\\u%04x', c:byte()) + end) .. '"' +end +local function val(v) + if v == nil then return "null" + elseif type(v) == "number" then return tostring(math.floor(v)) + elseif type(v) == "table" then + local parts = {} + for i = 1, #v do parts[i] = esc(v[i]) end + return "[" .. table.concat(parts, ",") .. "]" + else return esc(v) end +end + +local probes = { + {2026,4,12,"moscow",false}, {2026,4,12,"goarch",false}, {2026,1,7,"moscow",false}, + {2025,12,25,"old",false}, {2026,3,4,"ruthenian",false},{2026,3,4,"ruthenian",true}, + {2025,12,9,"old",false}, {2026,2,16,"oca",false}, {2026,8,15,"antiochian",false}, + {2026,9,18,"finnish",false},{2027,5,2,"serbian",false}, {2026,6,28,"constantinople",false}, +} + +local fields = { + {"date","iso"}, {"weekday","weekday"}, {"jurisdiction","jurisdiction"}, + {"calendar","calendar"}, {"churchDate","church_date"}, {"season","season"}, + {"cycle","cycle"}, {"feast","feast"}, {"rank","rank"}, {"sign","sign"}, + {"tradition","tradition"}, {"title","title"}, {"fast","fast"}, + {"fastLabel","fast_label"}, {"emoji","emoji"}, {"reason","reason"}, + {"obligation","obligation"}, {"binds","binds"}, + {"commemorations","commemorations"}, +} + +local out, firstkey = {}, true +for _, p in ipairs(probes) do + local jdn = assert(typikon.jdn(p[1], p[2], p[3])) + local day = assert(typikon.describe(jdn, { jurisdiction = p[4], typikon = p[5] })) + local parts = {} + for _, f in ipairs(fields) do + parts[#parts + 1] = esc(f[1]) .. ":" .. val(day[f[2]]) + end + local py, pm, pd = typikon.ymd(day.pascha_jdn) + parts[#parts + 1] = esc("pascha") .. ":" .. esc(string.format("%04d-%02d-%02d", py, pm, pd)) + parts[#parts + 1] = esc("daysToPascha") .. ":" .. tostring(day.days_to_pascha) + parts[#parts + 1] = esc("julianOffset") .. ":" .. tostring(day.julian_offset) + local key = string.format("%04d-%02d-%02d|%s|%d", p[1], p[2], p[3], p[4], p[5] and 1 or 0) + out[#out + 1] = (firstkey and "" or ",") .. esc(key) .. ":{" .. table.concat(parts, ",") .. "}" + firstkey = false +end + +local f = assert(io.open(arg[1], "w")) +f:write("{" .. table.concat(out) .. "}\n") +f:close() diff --git a/tests/parity/probe.mjs b/tests/parity/probe.mjs new file mode 100644 index 0000000..44a557b --- /dev/null +++ b/tests/parity/probe.mjs @@ -0,0 +1,12 @@ +// Node, by import. +import { readFileSync, writeFileSync } from "node:fs"; +import { describeIso } from "typikon"; +import { shape } from "./shape.mjs"; + +const [probesPath, outPath] = process.argv.slice(2); +const out = {}; +for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8"))) { + out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = + shape(describeIso(date, { jurisdiction, typikon })); +} +writeFileSync(outPath, JSON.stringify(out)); diff --git a/tests/parity/probe.py b/tests/parity/probe.py new file mode 100644 index 0000000..f245cc1 --- /dev/null +++ b/tests/parity/probe.py @@ -0,0 +1,37 @@ +"""The Python bindings.""" +import datetime +import json +import sys + +import typikon + +probes_path, out_path = sys.argv[1:3] +out = {} +for date, jurisdiction, tk in json.load(open(probes_path)): + y, m, d = map(int, date.split("-")) + day = typikon.describe(datetime.date(y, m, d), jurisdiction=jurisdiction, typikon=tk) + out[f"{date}|{jurisdiction}|{int(tk)}"] = { + "date": day.iso, + "weekday": day.weekday, + "jurisdiction": day.jurisdiction, + "calendar": day.calendar, + "churchDate": day.church_date, + "season": day.season, + "cycle": day.cycle, + "feast": day.feast, + "rank": day.rank, + "sign": day.sign, + "tradition": day.tradition, + "title": day.title, + "commemorations": day.commemorations, + "fast": day.fast, + "fastLabel": day.fast_label, + "emoji": day.emoji, + "reason": day.reason, + "obligation": day.obligation, + "binds": day.binds, + "pascha": day.pascha.isoformat(), + "daysToPascha": day.days_to_pascha, + "julianOffset": day.julian_offset, + } +json.dump(out, open(out_path, "w"), ensure_ascii=False) diff --git a/tests/parity/probe.ts b/tests/parity/probe.ts new file mode 100644 index 0000000..82a0fcd --- /dev/null +++ b/tests/parity/probe.ts @@ -0,0 +1,36 @@ +// TypeScript, compiled under --strict and then run. Every field is annotated +// on the way out, so a type that has drifted from what the package returns is +// a compile error rather than a surprise at run time. +import { readFileSync, writeFileSync } from "node:fs"; +import { describeIso, week, pascha, jurisdictions, type Day, type Options } from "typikon"; + +const [probesPath, outPath] = process.argv.slice(2); +type Probe = [string, string, boolean]; + +const out: Record> = {}; +for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8")) as Probe[]) { + const options: Options = { jurisdiction, typikon }; + const d: Day = describeIso(date, options); + const season: string | null = d.season; + const commemorations: string[] = d.commemorations; + const daysToPascha: number = d.daysToPascha; + out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = { + date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, + calendar: d.calendar, churchDate: d.churchDate, season, + cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign, + tradition: d.tradition, title: d.title, commemorations, + fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, + obligation: d.obligation, binds: d.binds, pascha: d.pascha, + daysToPascha, julianOffset: d.julianOffset, + }; +} + +// The rest of the surface, so nothing is typed only where it is called. +const seven: Day[] = week(new Date(2026, 3, 12), 7, { jurisdiction: "moscow" }); +const p: Date = pascha(2027, "old"); +const keys: string[] = jurisdictions().map((j) => j.key); +if (seven.length !== 7 || p.getFullYear() !== 2027 || keys.length < 20) { + throw new Error("the rest of the surface did not answer"); +} + +writeFileSync(outPath, JSON.stringify(out)); diff --git a/tests/parity/probes.json b/tests/parity/probes.json new file mode 100644 index 0000000..43a4fa4 --- /dev/null +++ b/tests/parity/probes.json @@ -0,0 +1,14 @@ +[ + ["2026-04-12", "moscow", false], + ["2026-04-12", "goarch", false], + ["2026-01-07", "moscow", false], + ["2025-12-25", "old", false], + ["2026-03-04", "ruthenian", false], + ["2026-03-04", "ruthenian", true], + ["2025-12-09", "old", false], + ["2026-02-16", "oca", false], + ["2026-08-15", "antiochian", false], + ["2026-09-18", "finnish", false], + ["2027-05-02", "serbian", false], + ["2026-06-28", "constantinople", false] +] diff --git a/tests/parity/shape.mjs b/tests/parity/shape.mjs new file mode 100644 index 0000000..2054621 --- /dev/null +++ b/tests/parity/shape.mjs @@ -0,0 +1,14 @@ +// The fields, named once for every JavaScript surface. Written out rather than +// spread, so that a field the package stops exporting is a missing key here and +// not a silently absent one. +export function shape(d) { + return { + date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, + calendar: d.calendar, churchDate: d.churchDate, season: d.season, + cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign, + tradition: d.tradition, title: d.title, commemorations: d.commemorations, + fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, + obligation: d.obligation, binds: d.binds, pascha: d.pascha, + daysToPascha: d.daysToPascha, julianOffset: d.julianOffset, + }; +} diff --git a/tests/parity/web/entry.mjs b/tests/parity/web/entry.mjs new file mode 100644 index 0000000..9b32abe --- /dev/null +++ b/tests/parity/web/entry.mjs @@ -0,0 +1,16 @@ +// A bare "typikon" import under a browser-targeted bundler, which is what a +// web app actually writes. It has to resolve to the web build and not the Node +// one: the exports map is the only thing making that true. +import init, { describeIso } from "typikon"; +import wasmUrl from "typikon/web/typikon_bg.wasm"; +import { shape } from "../shape.mjs"; + +window.typikonProbe = async (probes) => { + await init({ module_or_path: wasmUrl }); + const out = {}; + for (const [date, jurisdiction, typikon] of probes) { + out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = + shape(describeIso(date, { jurisdiction, typikon })); + } + return out; +}; diff --git a/tests/parity/web/extract.py b/tests/parity/web/extract.py new file mode 100644 index 0000000..b98b18d --- /dev/null +++ b/tests/parity/web/extract.py @@ -0,0 +1,16 @@ +"""Pull the probe result back out of the DOM the browser printed.""" +import html +import json +import re +import sys + +dom = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r'
(.*?)
', dom, re.S) +if not match: + sys.exit("the page produced no result element") +text = html.unescape(match.group(1)) +if text == "running": + sys.exit("the page never finished; the wasm probably failed to load") +if text.startswith("ERROR"): + sys.exit(text[:600]) +json.dump(json.loads(text), open(sys.argv[2], "w"), ensure_ascii=False) diff --git a/tests/parity/web/index.html b/tests/parity/web/index.html new file mode 100644 index 0000000..1d64e14 --- /dev/null +++ b/tests/parity/web/index.html @@ -0,0 +1,14 @@ + + +typikon parity +
running
+