Files
typikon/tests/capi.rs
T

401 lines
15 KiB
Rust
Raw Normal View History

//! The C ABI, exercised as a C caller would use it.
//!
//! These run only under `--features capi`, which is what builds the module.
//! They cover three things a Rust test can check and a C compiler cannot: that
//! the answers match what the crate gives natively, that the pointers an
//! accessor hands out stay put, and that the header in `include/` still
//! describes the library that is actually built.
#![cfg(feature = "capi")]
use std::ffi::{c_char, CStr, CString};
use std::ptr;
use typikon::capi::*;
/// Borrow a returned pointer as a `str`, or `None` for NULL.
///
/// # Safety
/// Only called on pointers these functions returned, which live until free.
fn s<'a>(p: *const c_char) -> Option<&'a str> {
if p.is_null() {
None
} else {
Some(unsafe { CStr::from_ptr(p) }.to_str().expect("utf-8"))
}
}
fn describe(iso: &str, jurisdiction: &str) -> *mut typikon_day {
let (y, m, d) = (
iso[0..4].parse().unwrap(),
iso[5..7].parse().unwrap(),
iso[8..10].parse().unwrap(),
);
let jdn = typikon_jdn_from_ymd(y, m, d);
assert_ne!(jdn, 0, "{iso} should be a date");
let key = CString::new(jurisdiction).unwrap();
let day = unsafe { typikon_describe(jdn, key.as_ptr()) };
assert!(!day.is_null(), "{iso} {jurisdiction} should reckon");
day
}
// ──────────────────────────────── dates ───────────────────────────────────
#[test]
fn jdn_round_trips() {
for (y, m, d) in [(1, 1, 1), (1583, 10, 15), (2026, 4, 12), (9999, 12, 31)] {
let jdn = typikon_jdn_from_ymd(y, m, d);
assert_ne!(jdn, 0, "{y}-{m}-{d}");
let (mut yy, mut mm, mut dd) = (0i32, 0u32, 0u32);
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut yy, &mut mm, &mut dd) };
assert_eq!(rc, 0);
assert_eq!((yy, mm, dd), (y, m, d));
}
}
#[test]
fn an_impossible_date_is_an_error_not_a_guess() {
assert_eq!(typikon_jdn_from_ymd(2026, 2, 30), 0);
let msg = s(typikon_last_error()).expect("a message");
assert!(msg.contains("2026-02-30"), "{msg}");
// And the next success clears it, so a caller that only checks the error
// pointer cannot read a stale one.
assert_ne!(typikon_jdn_from_ymd(2026, 2, 28), 0);
assert!(typikon_last_error().is_null());
}
#[test]
fn ymd_out_params_are_each_optional() {
let jdn = typikon_jdn_from_ymd(2026, 4, 12);
let mut year = 0i32;
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut year, ptr::null_mut(), ptr::null_mut()) };
assert_eq!(rc, 0);
assert_eq!(year, 2026);
}
#[test]
fn a_julian_day_outside_the_years_is_refused() {
let mut year = 12345i32;
let rc = unsafe { typikon_ymd_from_jdn(-1, &mut year, ptr::null_mut(), ptr::null_mut()) };
assert_eq!(rc, -1);
assert_eq!(year, 12345, "out-params are untouched on failure");
assert!(s(typikon_last_error()).is_some());
}
// ───────────────────────────────── a day ──────────────────────────────────
#[test]
fn a_day_answers_what_the_crate_answers() {
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
let native = typikon::day::describe(
typikon::calendars::Date::parse_iso("2026-04-12").unwrap(),
jur,
);
let day = describe("2026-04-12", "moscow");
unsafe {
assert_eq!(s(typikon_day_iso(day)), Some("2026-04-12"));
assert_eq!(s(typikon_day_title(day)), Some(native.title()));
assert_eq!(s(typikon_day_fast(day)), Some(native.fast.key()));
assert_eq!(s(typikon_day_emoji(day)), Some(native.emoji));
assert_eq!(s(typikon_day_jurisdiction(day)), Some(native.jurisdiction));
assert_eq!(s(typikon_day_church_date(day)), Some(&*native.church_date));
assert_eq!(typikon_day_days_to_pascha(day), native.days_to_pascha);
assert_eq!(typikon_day_pascha(day), native.pascha.jdn());
assert_eq!(typikon_day_julian_offset(day), native.julian_offset);
assert_eq!(
typikon_day_commemoration_count(day) as usize,
native.commemorations.len()
);
typikon_day_free(day);
}
}
#[test]
fn the_json_is_the_document_the_command_line_emits() {
let day = describe("2026-01-07", "moscow");
let json = s(unsafe { typikon_day_json(day) }).expect("json");
let parsed: serde_json::Value = serde_json::from_str(json).expect("valid json");
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
let native = typikon::day::describe(
typikon::calendars::Date::parse_iso("2026-01-07").unwrap(),
jur,
);
let expected = serde_json::to_value(native.to_json()).unwrap();
assert_eq!(parsed, expected);
unsafe { typikon_day_free(day) };
}
#[test]
fn an_accessor_returns_the_same_pointer_every_time() {
// The point of building the strings once: a statusline that reads `title`
// on every redraw must not allocate on every redraw. An earlier version
// did, and kept every allocation alive until free.
let day = describe("2026-04-12", "moscow");
unsafe {
let first = typikon_day_title(day);
for _ in 0..10_000 {
assert_eq!(typikon_day_title(day), first);
}
typikon_day_free(day);
}
}
#[test]
fn a_null_day_answers_null_rather_than_crashing() {
let n: *const typikon_day = ptr::null();
unsafe {
assert!(typikon_day_json(n).is_null());
assert!(typikon_day_title(n).is_null());
assert!(typikon_day_season(n).is_null());
assert!(typikon_day_commemoration(n, 0).is_null());
assert_eq!(typikon_day_commemoration_count(n), 0);
assert_eq!(typikon_day_is_obligatory(n), 0);
assert_eq!(typikon_day_days_to_pascha(n), 0);
assert_eq!(typikon_day_pascha(n), 0);
assert_eq!(typikon_day_julian_offset(n), 0);
typikon_day_free(ptr::null_mut()); // accepted and ignored
}
}
#[test]
fn a_commemoration_past_the_end_is_null() {
let day = describe("2026-04-12", "moscow");
unsafe {
let n = typikon_day_commemoration_count(day);
assert!(typikon_day_commemoration(day, n).is_null());
assert!(typikon_day_commemoration(day, u32::MAX).is_null());
if n > 0 {
assert!(!typikon_day_commemoration(day, 0).is_null());
}
typikon_day_free(day);
}
}
#[test]
fn a_jurisdiction_nobody_keeps_is_an_error() {
let key = CString::new("shire").unwrap();
let day = unsafe { typikon_describe(typikon_today(), key.as_ptr()) };
assert!(day.is_null());
let msg = s(typikon_last_error()).expect("a message");
assert!(msg.contains("shire"), "{msg}");
}
#[test]
fn a_null_jurisdiction_takes_the_remembered_default() {
let day = unsafe { typikon_describe(typikon_jdn_from_ymd(2026, 4, 12), ptr::null()) };
assert!(!day.is_null());
unsafe {
assert!(s(typikon_day_jurisdiction(day)).is_some());
typikon_day_free(day);
}
}
#[test]
fn the_discipline_flag_moves_what_is_shown_but_not_what_binds() {
// The Byzantine Catholic case the flag exists for: on a Wednesday of the
// Great Fast, Ruthenian law requires nothing, while the Typikon it inherits
// keeps xerophagy.
let jdn = typikon_jdn_from_ymd(2026, 3, 4); // Wednesday, Great Fast
let key = CString::new("ruthenian").unwrap();
unsafe {
let law = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 0);
let typikon = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 1);
assert!(!law.is_null() && !typikon.is_null());
assert_ne!(
s(typikon_day_fast(law)),
s(typikon_day_fast(typikon)),
"the Typikon should keep more than the law requires"
);
assert_eq!(
s(typikon_day_binds(law)),
s(typikon_day_binds(typikon)),
"what binds is the law either way"
);
typikon_day_free(law);
typikon_day_free(typikon);
}
}
#[test]
fn the_usage_can_be_overridden() {
// Fish in the Nativity Fast: Greek practice permits it on a weekday before
// 17 December, Slavic practice keeps it to Saturdays and Sundays.
let jdn = typikon_jdn_from_ymd(2025, 12, 9); // a Tuesday
let key = CString::new("old").unwrap();
let (greek, slavic) = (CString::new("greek").unwrap(), CString::new("slavic").unwrap());
unsafe {
let g = typikon_describe_full(jdn, key.as_ptr(), greek.as_ptr(), -1);
let sl = typikon_describe_full(jdn, key.as_ptr(), slavic.as_ptr(), -1);
assert_eq!(s(typikon_day_tradition(g)), Some("greek"));
assert_eq!(s(typikon_day_tradition(sl)), Some("slavic"));
typikon_day_free(g);
typikon_day_free(sl);
}
}
#[test]
fn an_unknown_usage_is_an_error() {
let key = CString::new("moscow").unwrap();
let bad = CString::new("frankish").unwrap();
let day = unsafe { typikon_describe_full(typikon_today(), key.as_ptr(), bad.as_ptr(), -1) };
assert!(day.is_null());
assert!(s(typikon_last_error()).is_some());
}
// ────────────────────────── without a day ─────────────────────────────────
#[test]
fn pascha_by_year_matches_the_published_dates() {
let old = CString::new("old").unwrap();
let west = CString::new("western").unwrap();
for (year, julian, gregorian) in [
(2026, "2026-04-12", "2026-04-05"),
(2027, "2027-05-02", "2027-03-28"),
(2028, "2028-04-16", "2028-04-16"),
] {
for (key, expected) in [(&old, julian), (&west, gregorian)] {
let jdn = unsafe { typikon_pascha(year, key.as_ptr()) };
let (mut y, mut m, mut d) = (0i32, 0u32, 0u32);
assert_eq!(unsafe { typikon_ymd_from_jdn(jdn, &mut y, &mut m, &mut d) }, 0);
assert_eq!(format!("{y:04}-{m:02}-{d:02}"), expected);
}
}
}
#[test]
fn pascha_refuses_a_jurisdiction_nobody_keeps() {
let key = CString::new("shire").unwrap();
assert_eq!(unsafe { typikon_pascha(2026, key.as_ptr()) }, 0);
assert!(s(typikon_last_error()).is_some());
}
#[test]
fn every_jurisdiction_is_listed_and_every_listed_key_reckons() {
let n = typikon_jurisdiction_count();
assert_eq!(
n as usize,
typikon::jurisdictions::JURISDICTIONS.len() + typikon::jurisdictions::CALENDARS.len()
);
for i in 0..n {
let key = s(typikon_jurisdiction_key(i)).expect("a key");
assert!(s(typikon_jurisdiction_name(i)).is_some_and(|x| !x.is_empty()));
assert!(s(typikon_jurisdiction_note(i)).is_some(), "note may be empty, not NULL");
let day = describe("2026-04-12", key);
unsafe { typikon_day_free(day) };
}
assert!(typikon_jurisdiction_key(n).is_null());
}
#[test]
fn the_version_is_the_crate_version() {
assert_eq!(s(typikon_version()), Some(env!("CARGO_PKG_VERSION")));
}
// ──────────────────────────────── the header ──────────────────────────────
/// Every symbol the library exports is declared in `include/typikon.h`, and
/// every function the header declares is exported.
///
/// A header is a promise made in a second file, and a second file drifts. This
/// is the cheapest check that it has not.
#[test]
fn the_header_describes_the_library() {
let src = std::fs::read_to_string("src/capi.rs").expect("src/capi.rs");
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
let mut exported: Vec<&str> = Vec::new();
for line in src.lines() {
// `fn typikon_foo(` in a line that declares an extern function, plus
// the names the accessor macro generates, which are its first argument.
if let Some(rest) = line.trim().strip_prefix("pub extern \"C\" fn ") {
exported.push(rest.split('(').next().unwrap());
} else if let Some(rest) = line.trim().strip_prefix("pub unsafe extern \"C\" fn ") {
exported.push(rest.split('(').next().unwrap());
} else if let Some(rest) = line.trim().strip_prefix("accessor!(") {
exported.push(rest.split(',').next().unwrap());
}
}
// The macro's own definition line reads as `fn $name(`; the names it
// generates are picked up from the invocations.
exported.retain(|n| !n.starts_with('$'));
assert!(exported.len() > 20, "found only {} symbols", exported.len());
for name in &exported {
assert!(
header.contains(&format!("{name}(")),
"{name} is exported but not declared in include/typikon.h"
);
}
for line in header.lines() {
let Some(start) = line.find("typikon_") else { continue };
if !line.contains('(') || line.trim_start().starts_with('*') {
continue;
}
let name: String = line[start..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !line[start + name.len()..].starts_with('(') {
continue; // a mention in prose, not a declaration
}
assert!(
exported.contains(&name.as_str()),
"{name} is declared in include/typikon.h but not exported"
);
}
}
/// The header hard-codes the ABI version so a caller can compare what it
/// compiled against with what it loaded.
#[test]
fn the_headers_abi_version_matches_the_library() {
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
let line = header
.lines()
.find(|l| l.starts_with("#define TYPIKON_ABI_VERSION"))
.expect("the header defines it");
let declared: u32 = line
.split_whitespace()
.next_back()
.unwrap()
.trim_end_matches('u')
.parse()
.expect("a number");
assert_eq!(declared, typikon_abi_version());
}
/// The LuaJIT binding declares the library it loads, and a declaration that
/// has drifted from the header is a segfault rather than a compile error.
#[test]
fn the_lua_binding_declares_the_whole_header() {
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
let lua = std::fs::read_to_string("examples/lua/typikon.lua").expect("the lua binding");
let cdef = {
let start = lua.find("ffi.cdef").expect("a cdef block");
let body = &lua[start..];
let end = body.find("]]").expect("the block ends");
&body[..end]
};
for line in header.lines() {
let Some(start) = line.find("typikon_") else { continue };
let name: String = line[start..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !line[start + name.len()..].starts_with('(') {
continue;
}
assert!(
cdef.contains(&format!("{name}(")),
"{name} is in the header but not in the lua cdef"
);
}
}