Give the interpreter real Celtic Cross meanings and --format/--lang support

- guidance.c now prints last, after the full spread; both it and main.c's
  new Celtic Cross readout pull real Waite card meanings via tarot_data.c
  instead of just naming cards.
- interpreter-cli gains --format text|html|json and --lang en|de, matching
  deck-engine - required linking engine/src/tarot_data.c and i18n.c into
  the interpreter binary (a narrow, documented exception to its earlier
  "no engine .c files" policy) and writing German translations for
  narrative.c's/guidance.c's own text.
This commit is contained in:
ml
2026-07-11 19:39:30 +02:00
parent 41422adf98
commit 1662d550c7
23 changed files with 1894 additions and 296 deletions
+219
View File
@@ -0,0 +1,219 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "../../engine/src/i18n.h"
#include "../src/guidance.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static DailyInterpretation make_interp(DaySignificance level, AspectType type, Body transiting) {
DailyInterpretation interp;
interp.top_item_count = 1;
interp.day_level = level;
interp.top_items[0].kind = ITEM_ASPECT;
interp.top_items[0].score = 9.5;
interp.top_items[0].aspect.transiting_planet = transiting;
interp.top_items[0].aspect.natal_planet = PLANET_MOON;
interp.top_items[0].aspect.type = type;
interp.top_items[0].aspect.orb = 0.5;
return interp;
}
static DailyInterpretation make_empty_interp(void) {
DailyInterpretation interp;
interp.top_item_count = 0;
interp.day_level = DAY_QUIET;
return interp;
}
static CelticCrossSpread make_spread(TarotCard attitude_card, bool attitude_reversed,
TarotCard outcome_card, bool outcome_reversed) {
CelticCrossSpread spread;
spread.positions[POSITION_ATTITUDE].card = attitude_card;
spread.positions[POSITION_ATTITUDE].reversed = attitude_reversed;
spread.positions[POSITION_OUTCOME].card = outcome_card;
spread.positions[POSITION_OUTCOME].reversed = outcome_reversed;
return spread;
}
static void test_harmonious_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_TRINE, PLANET_JUPITER);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "already meeting the day in the right spirit") != NULL);
assert(strstr(text, "Jupiter") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
assert(strstr(text, "(Reversed)") == NULL);
fclose(f);
printf("PASS test_harmonious_upright\n");
}
static void test_discordant_reversed(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_DEVIL, true, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "Both the day and your own footing are unsettled") != NULL);
assert(strstr(text, "Saturn") != NULL);
assert(strstr(text, "The Devil (Reversed): Evil fatality") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
fclose(f);
printf("PASS test_discordant_reversed\n");
}
static void test_neutral_conjunction_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_CONJUNCTION, PLANET_PLUTO);
CelticCrossSpread spread = make_spread(CARD_EMPEROR, false, CARD_MOON, true);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "Today concentrates whatever you already bring to it") != NULL);
assert(strstr(text, "Pluto") != NULL);
assert(strstr(text, "The Emperor: Stability, power") != NULL);
assert(strstr(text, "The Moon (Reversed): Instability, inconstancy") != NULL);
fclose(f);
printf("PASS test_neutral_conjunction_upright\n");
}
/* The stance sentence itself doesn't change with intensity - only the
* sentence introducing the transiting planet does (see k_intro in
* guidance.c). These three cover the remaining day levels below Major
* (which test_harmonious_upright already covers). */
static void test_significant_day_uses_significant_intro(void) {
DailyInterpretation interp = make_interp(DAY_SIGNIFICANT, ASPECT_SEXTILE, PLANET_VENUS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "clearly active today") != NULL);
assert(strstr(text, "Venus") != NULL);
fclose(f);
printf("PASS test_significant_day_uses_significant_intro\n");
}
static void test_notable_day_uses_notable_intro(void) {
DailyInterpretation interp = make_interp(DAY_NOTABLE, ASPECT_SEXTILE, PLANET_MARS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "mild touch from Mars") != NULL);
fclose(f);
printf("PASS test_notable_day_uses_notable_intro\n");
}
static void test_quiet_day_with_a_faint_item_still_names_it(void) {
DailyInterpretation interp = make_interp(DAY_QUIET, ASPECT_SEXTILE, PLANET_MERCURY);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "only faintly active today") != NULL);
assert(strstr(text, "Mercury") != NULL);
fclose(f);
printf("PASS test_quiet_day_with_a_faint_item_still_names_it\n");
}
static void test_no_aspects_at_all_falls_back_to_attitude_only(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "astrologically quiet day") != NULL);
assert(strstr(text, "trust your current footing") != NULL);
assert(strstr(text, "The Star: Loss, theft") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_falls_back_to_attitude_only\n");
}
static void test_no_aspects_at_all_respects_reversed_attitude(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, true, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "quietly re-settle your own footing") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_respects_reversed_attitude\n");
}
/* Proves guidance_print() actually routes through i18n_get() rather
* than just printing the hardcoded fallback. Run last: i18n_load()
* replaces the process-global catalog for the rest of the binary's
* lifetime. */
static void test_translation_catalog_overrides_fallback_text(void) {
char path[] = "/tmp/deck_guidance_test_XXXXXX";
int fd = mkstemp(path);
assert(fd >= 0);
FILE *f = fdopen(fd, "w");
fprintf(f, "guidance.stance.discordant.upright=UEBERSETZTE HALTUNG\n");
fprintf(f, "guidance.intro.major=UEBERSETZTE EINLEITUNG %%s\n");
fprintf(f, "guidance.planet.saturn=UEBERSETZTER SATURN\n");
fprintf(f, "guidance.attitude_intro=UEBERSETZTE HALTUNGSKARTE\n");
fclose(f);
assert(i18n_load(path));
unlink(path);
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *out = tmpfile();
guidance_print(out, "", &interp, &spread);
const char *text = slurp(out);
assert(strstr(text, "UEBERSETZTE HALTUNG") != NULL);
assert(strstr(text, "UEBERSETZTE EINLEITUNG UEBERSETZTER SATURN") != NULL);
assert(strstr(text, "UEBERSETZTE HALTUNGSKARTE") != NULL);
assert(strstr(text, "Your instincts are sound") == NULL); /* English fallback must not leak through */
fclose(out);
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
int main(void) {
test_harmonious_upright();
test_discordant_reversed();
test_neutral_conjunction_upright();
test_significant_day_uses_significant_intro();
test_notable_day_uses_notable_intro();
test_quiet_day_with_a_faint_item_still_names_it();
test_no_aspects_at_all_falls_back_to_attitude_only();
test_no_aspects_at_all_respects_reversed_attitude();
test_translation_catalog_overrides_fallback_text();
printf("All guidance tests passed.\n");
return 0;
}