mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b597302a | ||
|
|
dde50192c7 | ||
|
|
5f26563334 | ||
|
|
33441113ce |
@@ -119,3 +119,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo build --release
|
cargo build --release
|
||||||
python3 tests/readme-examples.py
|
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
|
||||||
|
|||||||
@@ -32,8 +32,20 @@ jobs:
|
|||||||
echo "tag=$tag crate=$crate"
|
echo "tag=$tag crate=$crate"
|
||||||
[ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
|
[ "$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
|
- 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
|
- name: Build wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
@@ -76,6 +88,17 @@ jobs:
|
|||||||
npm/build.sh
|
npm/build.sh
|
||||||
node npm/test.mjs
|
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
|
- name: Publish
|
||||||
env:
|
env:
|
||||||
# A project-scoped token, not the account-wide one. PyPI's trusted
|
# A project-scoped token, not the account-wide one. PyPI's trusted
|
||||||
|
|||||||
@@ -4,6 +4,50 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out.
|
|||||||
**Calendar** entries change what a day reports, whatever the version number
|
**Calendar** entries change what a day reports, whatever the version number
|
||||||
does.
|
does.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The published description says what the package covers, and says the same
|
||||||
|
thing in both registries. PyPI takes its summary from `pyproject.toml`, which
|
||||||
|
1.1.1 did not update, so it went out with the old one-line text while npm got
|
||||||
|
the new one. The two READMEs had drifted apart as well: npm's opened without
|
||||||
|
mentioning the Byzantine Catholic churches at all. All four now carry one
|
||||||
|
sentence, and a test fails if any of them moves without the others.
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- The README no longer explains how the project tests or releases itself. That
|
||||||
|
belongs to whoever works on it, and it is in `docs/` where they will look;
|
||||||
|
a reader who came to find out what a day of the Great Fast permits does not
|
||||||
|
need it.
|
||||||
|
- 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
|
## 1.1.0
|
||||||
|
|
||||||
New ways in, and no change to any day: nothing here moves a feast, a fast
|
New ways in, and no change to any day: nothing here moves a feast, a fast
|
||||||
|
|||||||
Generated
+1
-1
@@ -404,7 +404,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
repository = "https://git.pumpin.online/funkpower/typikon"
|
repository = "https://git.pumpin.online/funkpower/typikon"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# typikon
|
# typikon
|
||||||
|
|
||||||
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
than fetched. The Byzantine Catholic churches are here too, on their own
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
calendars and, where the law has been sourced, under their own particular law.
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
Everything derives from the paschalion and the fixed calendar, so it needs no
|
Everything derives from the paschalion and the fixed calendar, so it needs no
|
||||||
network, cannot go stale, and answers for any year you ask about. No API key,
|
network, cannot go stale, and answers for any year you ask about. No API key,
|
||||||
@@ -555,12 +555,6 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Releasing
|
|
||||||
|
|
||||||
`main` is always releasable; a `vX.Y.Z` tag on it builds the wheels and publishes
|
|
||||||
to PyPI. [docs/releasing.md](https://git.pumpin.online/funkpower/typikon/src/branch/main/docs/releasing.md) has the workflow, and what has to
|
|
||||||
be set up before the first one.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
GPL-2.0-only. The source for any published release is the source distribution
|
GPL-2.0-only. The source for any published release is the source distribution
|
||||||
|
|||||||
@@ -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
|
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.
|
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:
|
For the npm package:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
# typikon
|
# typikon
|
||||||
|
|
||||||
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
than fetched. No network, no data files, no service to be up.
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
|
No network, no data files, no service to be up.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install typikon
|
npm install typikon
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "typikon",
|
"name": "typikon",
|
||||||
"version": "@VERSION@",
|
"version": "@VERSION@",
|
||||||
"description": "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched",
|
"description": "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings.",
|
||||||
"license": "GPL-2.0-only",
|
"license": "GPL-2.0-only",
|
||||||
"homepage": "https://git.pumpin.online/funkpower/typikon",
|
"homepage": "https://git.pumpin.online/funkpower/typikon",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
"types": "./web/typikon.d.ts",
|
"types": "./web/typikon.d.ts",
|
||||||
"default": "./web/typikon.js"
|
"default": "./web/typikon.js"
|
||||||
},
|
},
|
||||||
|
"./node/*": "./node/*",
|
||||||
|
"./web/*": "./web/*",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! The packaging metadata, which lives in three files and is published to two
|
||||||
|
//! registries.
|
||||||
|
//!
|
||||||
|
//! `Cargo.toml` describes the crate, `pyproject.toml` is what maturin puts on
|
||||||
|
//! PyPI, and `npm/package.json.in` is what npm shows. Nothing makes them agree
|
||||||
|
//! and nothing notices when they stop: a description edited in one of the three
|
||||||
|
//! reaches one registry and not the other, and the wrong one is then frozen
|
||||||
|
//! into a release, because neither registry lets published metadata be edited.
|
||||||
|
//! That has happened once already.
|
||||||
|
//!
|
||||||
|
//! The two READMEs open with the same sentence, because that paragraph is the
|
||||||
|
//! first thing shown on both registry pages and is what most people will read
|
||||||
|
//! instead of the description field.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// The value of a `key = "…"` line, from the first section that has one.
|
||||||
|
fn toml_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
let Some(rest) = line.strip_prefix(key) else { continue };
|
||||||
|
let Some(rest) = rest.trim_start().strip_prefix('=') else { continue };
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `{key} = \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of a `"key": "…"` line. Enough for a hand-written file with no
|
||||||
|
/// escapes in it, and the test below fails loudly rather than quietly if that
|
||||||
|
/// ever stops being true.
|
||||||
|
fn json_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let needle = format!("\"{key}\":");
|
||||||
|
for line in text.lines() {
|
||||||
|
let Some(rest) = line.trim().strip_prefix(&needle) else { continue };
|
||||||
|
let rest = rest.trim().trim_end_matches(',').trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
assert!(!v.contains('\\'), "{path}: {key} has escapes this reader cannot handle");
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `\"{key}\": \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_three_descriptions_are_the_same_sentence() {
|
||||||
|
let cargo = toml_string("Cargo.toml", "description");
|
||||||
|
let pyproject = toml_string("pyproject.toml", "description");
|
||||||
|
let npm = json_string("npm/package.json.in", "description");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cargo, pyproject,
|
||||||
|
"Cargo.toml and pyproject.toml disagree; pyproject.toml is the one PyPI shows"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cargo, npm,
|
||||||
|
"Cargo.toml and npm/package.json.in disagree; the latter is the one npm shows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_description_says_what_the_crate_is_called() {
|
||||||
|
// Cheap guard against a description edited into something that no longer
|
||||||
|
// describes this package at all.
|
||||||
|
let d = toml_string("Cargo.toml", "description");
|
||||||
|
assert!(d.len() > 40, "description is too short to be one: {d:?}");
|
||||||
|
assert!(!d.ends_with(' '), "description has a trailing space");
|
||||||
|
assert!(d.contains("Orthodox"), "description no longer mentions Orthodox: {d:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening paragraph of a README, unwrapped to one line.
|
||||||
|
fn lede(path: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let body = text
|
||||||
|
.split("\n\n")
|
||||||
|
.find(|p| !p.trim().is_empty() && !p.trim_start().starts_with('#'))
|
||||||
|
.unwrap_or_else(|| panic!("{path}: no opening paragraph"));
|
||||||
|
body.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_readmes_open_with_the_description() {
|
||||||
|
// A registry page leads with the README, not with the description field,
|
||||||
|
// so the sentence a reader actually meets is this one. npm's README is a
|
||||||
|
// separate file from the crate's and had gone its own way: it described
|
||||||
|
// the package without mentioning that it covers Byzantine Catholic
|
||||||
|
// churches at all.
|
||||||
|
let description = toml_string("Cargo.toml", "description");
|
||||||
|
assert_eq!(
|
||||||
|
lede("README.md"),
|
||||||
|
description,
|
||||||
|
"README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lede("npm/README.md"),
|
||||||
|
description,
|
||||||
|
"npm/README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+244
@@ -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 ))
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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 <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <typikon.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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()
|
||||||
@@ -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));
|
||||||
@@ -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)
|
||||||
@@ -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<string, Record<string, unknown>> = {};
|
||||||
|
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));
|
||||||
@@ -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]
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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'<pre id="out">(.*?)</pre>', 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)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>typikon parity</title>
|
||||||
|
<pre id="out">running</pre>
|
||||||
|
<script type="module">
|
||||||
|
import "./entry.js";
|
||||||
|
const probes = await (await fetch("./probes.json")).json();
|
||||||
|
try {
|
||||||
|
document.getElementById("out").textContent =
|
||||||
|
JSON.stringify(await window.typikonProbe(probes));
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("out").textContent = "ERROR " + (e && e.stack || e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -706,3 +706,27 @@ fn date_arithmetic_saturates_instead_of_overflowing() {
|
|||||||
assert_eq!(d.iso().len(), 10);
|
assert_eq!(d.iso().len(), 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The published description counts the churches, on PyPI and on npm, where
|
||||||
|
/// nobody editing this table will see it. Adding a jurisdiction without saying
|
||||||
|
/// so makes that description quietly wrong, so the count is pinned here and
|
||||||
|
/// the failure names the two places to correct.
|
||||||
|
#[test]
|
||||||
|
fn the_published_description_still_counts_the_churches_correctly() {
|
||||||
|
let catholic = ["ruthenian", "ugcc", "melkite", "romanian-catholic"];
|
||||||
|
let orthodox = JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|j| !catholic.contains(&j.key))
|
||||||
|
.count();
|
||||||
|
let byzantine_catholic = JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|j| catholic.contains(&j.key))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(orthodox, byzantine_catholic, CALENDARS.len()),
|
||||||
|
(20, 4, 3),
|
||||||
|
"the counts moved; update `description` in Cargo.toml (PyPI) and in \
|
||||||
|
npm/package.json.in (npm), and the README's opening"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user