Add core engine: tarot + astrology reading generator with CLI and i18n
Plain-C, dependency-free engine that produces a personalized daily Celtic Cross tarot spread and natal-chart/transit astrology reading, plus a standalone CLI (dist/deck-engine) and run.sh wrapper. Includes English/German output via a simple key=value translation format, and docs/diagrams describing the architecture and I/O.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../src/reading.h"
|
||||
#include "../src/i18n.h"
|
||||
|
||||
static void test_tarot_determinism(void) {
|
||||
CelticCrossSpread a, b, c;
|
||||
tarot_draw_celtic_cross("2026-07-03", &a);
|
||||
tarot_draw_celtic_cross("2026-07-03", &b);
|
||||
tarot_draw_celtic_cross("2026-07-04", &c);
|
||||
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
assert(a.positions[i].card == b.positions[i].card);
|
||||
assert(a.positions[i].reversed == b.positions[i].reversed);
|
||||
}
|
||||
|
||||
int differs = 0;
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
if (a.positions[i].card != c.positions[i].card ||
|
||||
a.positions[i].reversed != c.positions[i].reversed) {
|
||||
differs = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(differs && "different seeds should (almost always) differ");
|
||||
|
||||
/* No repeated cards within a single spread. */
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
for (int j = i + 1; j < TAROT_SPREAD_SIZE; j++) {
|
||||
assert(a.positions[i].card != a.positions[j].card);
|
||||
}
|
||||
}
|
||||
|
||||
printf("PASS test_tarot_determinism\n");
|
||||
}
|
||||
|
||||
static void test_natal_sun_sign(void) {
|
||||
/* 2000-06-15 noon UTC: the Sun is well inside Gemini (60-90 degrees),
|
||||
* away from any sign boundary, so this is a stable regression check
|
||||
* against the ephemeris math. Birth location doesn't affect the Sun's
|
||||
* geocentric ecliptic longitude, only the Ascendant. */
|
||||
BirthData birth = {
|
||||
.year = 2000, .month = 6, .day = 15,
|
||||
.hour = 12, .minute = 0,
|
||||
.utc_offset_hours = 0.0,
|
||||
.latitude = 52.5, .longitude = 13.4,
|
||||
};
|
||||
|
||||
NatalChart chart;
|
||||
astro_compute_natal_chart(&birth, &chart);
|
||||
|
||||
assert(chart.bodies[PLANET_SUN].sign == SIGN_GEMINI);
|
||||
printf("PASS test_natal_sun_sign\n");
|
||||
}
|
||||
|
||||
static void test_reading_generate_smoke(void) {
|
||||
BirthData birth = {
|
||||
.year = 1990, .month = 5, .day = 14,
|
||||
.hour = 14, .minute = 32,
|
||||
.utc_offset_hours = 2.0,
|
||||
.latitude = 52.52, .longitude = 13.405,
|
||||
};
|
||||
|
||||
DailyReading r1, r2;
|
||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r1);
|
||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r2);
|
||||
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
||||
assert(r1.spread.positions[i].reversed == r2.spread.positions[i].reversed);
|
||||
}
|
||||
for (int b = 0; b < NUM_BODIES; b++) {
|
||||
assert(r1.natal.bodies[b].ecliptic_longitude == r2.natal.bodies[b].ecliptic_longitude);
|
||||
assert(r1.transits.bodies[b].ecliptic_longitude == r2.transits.bodies[b].ecliptic_longitude);
|
||||
}
|
||||
assert(r1.transits.aspect_count == r2.transits.aspect_count);
|
||||
|
||||
printf("PASS test_reading_generate_smoke\n");
|
||||
}
|
||||
|
||||
/* Run with cwd == engine/ (as `make test` does), so the shipped
|
||||
* engine/i18n/ .lang files are reachable as i18n/<code>.lang. */
|
||||
static void test_i18n_fallback_and_translation(void) {
|
||||
/* No catalog loaded yet: every lookup returns the caller's fallback. */
|
||||
assert(strcmp(i18n_get("does.not.exist", "fallback text"), "fallback text") == 0);
|
||||
|
||||
bool loaded = i18n_load("i18n/de.lang");
|
||||
assert(loaded && "expected engine/i18n/de.lang to exist and load");
|
||||
assert(strcmp(i18n_get("body.sun", "Sun"), "Sonne") == 0);
|
||||
assert(strcmp(i18n_get("does.not.exist", "fallback text"), "fallback text") == 0);
|
||||
|
||||
/* astro_ and tarot_ accessors route through the same loaded catalog. */
|
||||
assert(strcmp(astro_body_name(PLANET_MOON), "Mond") == 0);
|
||||
assert(strcmp(astro_sign_name(SIGN_LEO), "Löwe") == 0);
|
||||
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
|
||||
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
||||
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0);
|
||||
|
||||
/* Loading a file that doesn't exist fails and leaves the previously
|
||||
* loaded catalog in place, rather than silently clearing it. */
|
||||
assert(!i18n_load("i18n/does-not-exist.lang"));
|
||||
assert(strcmp(i18n_get("body.sun", "Sun"), "Sonne") == 0);
|
||||
|
||||
/* Loading a different language replaces the catalog wholesale. */
|
||||
loaded = i18n_load("i18n/en.lang");
|
||||
assert(loaded);
|
||||
assert(strcmp(astro_body_name(PLANET_MOON), "Moon") == 0);
|
||||
|
||||
printf("PASS test_i18n_fallback_and_translation\n");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_tarot_determinism();
|
||||
test_natal_sun_sign();
|
||||
test_reading_generate_smoke();
|
||||
test_i18n_fallback_and_translation();
|
||||
printf("All smoke tests passed.\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user