Add JSON output format and a separate interpreter binary
deck-engine gains --format json (language-independent, slug-based) so a new dist/interpreter-cli can score how significant a day's transits are without linking the engine itself — it depends only on the JSON shape, via its own minimal parser. Also documents the full reading pipeline end to end (CLAUDE.md, README, docs/) and adds docs/reading.md, a non-technical explanation of what a daily reading contains and means.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
# data once filled in — never commit it.
|
# data once filled in — never commit it.
|
||||||
/dist
|
/dist
|
||||||
/engine/build
|
/engine/build
|
||||||
|
/interpreter/build
|
||||||
|
|
||||||
*.o
|
*.o
|
||||||
core
|
core
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Two ways to run a reading:
|
|||||||
dist/deck-engine --seed "2026-07-03" \
|
dist/deck-engine --seed "2026-07-03" \
|
||||||
--birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
|
--birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
|
||||||
--birth-lat 52.5200 --birth-lon 13.4050 \
|
--birth-lat 52.5200 --birth-lon 13.4050 \
|
||||||
[--date YYYY-MM-DDTHH:MM] [--format text|html] [--lang en|de] [--i18n-dir <path>]
|
[--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] [--i18n-dir <path>]
|
||||||
|
|
||||||
# 2. dist/run.sh [text|html] [lang] — wraps the binary for daily use: seed
|
# 2. dist/run.sh [text|html] [lang] — wraps the binary for daily use: seed
|
||||||
# and --date come from the OS clock (today's date / current UTC time),
|
# and --date come from the OS clock (today's date / current UTC time),
|
||||||
@@ -76,6 +76,7 @@ deck_in_a_dash/
|
|||||||
tests/smoke_test.c
|
tests/smoke_test.c
|
||||||
scripts/run.sh, scripts/user.properties.template
|
scripts/run.sh, scripts/user.properties.template
|
||||||
Makefile
|
Makefile
|
||||||
|
interpreter/ Separate module + binary; see "Interpretation" below.
|
||||||
dist/ Build output (gitignored-style; see Build & run).
|
dist/ Build output (gitignored-style; see Build & run).
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -181,6 +182,73 @@ into the Pebble watchapp unchanged later. Only `reading_print_text`/
|
|||||||
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
|
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
|
||||||
(reads a file from disk) are desktop-only and won't port as-is.
|
(reads a file from disk) are desktop-only and won't port as-is.
|
||||||
|
|
||||||
|
## Interpretation (`interpreter/`)
|
||||||
|
|
||||||
|
A second, separate top-level module and **its own binary**
|
||||||
|
(`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how
|
||||||
|
significant a day's transits are* — the first piece of a larger "turn the
|
||||||
|
raw reading into an actual narrative" effort described in project chat
|
||||||
|
history, not yet producing any narrative text. `deck-engine` itself is
|
||||||
|
deliberately unchanged by this beyond gaining `--format json`; the two
|
||||||
|
binaries compose over that JSON, never by linking together:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd engine && make # -> dist/deck-engine (unchanged; --format json is new)
|
||||||
|
cd interpreter && make # -> dist/interpreter-cli
|
||||||
|
cd interpreter && make test # builds and runs both interpreter/tests/*_test.c
|
||||||
|
|
||||||
|
dist/deck-engine ... --format json | dist/interpreter-cli
|
||||||
|
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores
|
||||||
|
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
|
||||||
|
score in `DailyInterpretation.top_items[]` (descending, evicting the
|
||||||
|
rest), and classifies the day into a `DaySignificance` enum (`DAY_QUIET`
|
||||||
|
... `DAY_MAJOR`) from the single highest-scoring item.
|
||||||
|
`interpretation_deserves_framing()` is true only at `DAY_MAJOR` — the
|
||||||
|
intended hook for giving the Celtic Cross reading a "this matters"
|
||||||
|
intro sentence naming `top_items[0]` on days that earn it; that
|
||||||
|
rendering doesn't exist yet, `main.c`'s report is plain text only.
|
||||||
|
- Score = `transiting-planet weight (slow/outer planets score higher) ×
|
||||||
|
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
|
||||||
|
(transits to natal Sun/Moon score higher than to other planets)`. The
|
||||||
|
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
|
||||||
|
thresholds in `significance.c` are hand-tuned, not derived from
|
||||||
|
anything physical — expect to retune them once real readings are
|
||||||
|
compared against how "major" a day actually feels.
|
||||||
|
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
|
||||||
|
the full `DailyReading` (natal, transits, spread), using the
|
||||||
|
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
|
||||||
|
(`astro.h`/`tarot.h`) rather than `astro_*_name()`/`tarot_*_name()` —
|
||||||
|
the JSON must stay identical regardless of `--lang`, since it's a
|
||||||
|
machine interchange format, not display text.
|
||||||
|
- **`json.c`/`json.h`** is a small hand-rolled recursive-descent JSON
|
||||||
|
parser (object/array/string/number/bool/null) - not a general-purpose
|
||||||
|
validating library, just enough to parse `deck-engine`'s own output.
|
||||||
|
Unlike the core engine, it uses `malloc`; `interpreter-cli` was never
|
||||||
|
meant to run on the watch itself.
|
||||||
|
- **`reading_io.c`** walks the parsed JSON down to `"transits"."aspects"`
|
||||||
|
and fills a `DailyReading` with just that (everything else is left
|
||||||
|
zeroed - `interpret_daily_reading` doesn't read `natal`/`spread`
|
||||||
|
either). An aspect naming a planet/aspect-type slug it doesn't
|
||||||
|
recognize, or a document missing `"transits"."aspects"` entirely (as
|
||||||
|
opposed to a present-but-empty array, which is a valid "no aspects
|
||||||
|
today"), makes the whole load fail rather than silently dropping data.
|
||||||
|
- **Deliberately header-only dependency on the engine, throughout**:
|
||||||
|
`significance.h`/`reading_io.h` `#include engine/src/reading.h` for the
|
||||||
|
struct/enum *definitions*, but `interpreter/Makefile` never compiles or
|
||||||
|
links any engine `.c` file (or the vendored Astronomy Engine) — every
|
||||||
|
test in `interpreter/tests/` runs against hand-built JSON text or
|
||||||
|
hand-built `DailyReading` values, with no real ephemeris/tarot-draw
|
||||||
|
call involved anywhere. Consequently `reading_io.c` (planet/aspect
|
||||||
|
slugs) and `main.c` (planet/aspect display names) each duplicate a
|
||||||
|
small local table rather than linking `astro.c` to reuse its own -
|
||||||
|
keep those in sync if the engine's slugs/names ever change.
|
||||||
|
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is
|
||||||
|
currently just `ITEM_ASPECT`); moon phase and house ingresses are
|
||||||
|
candidate future item kinds but aren't scored yet.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
- Engine code (`engine/src/`, `engine/scripts/`, `engine/Makefile`):
|
- Engine code (`engine/src/`, `engine/scripts/`, `engine/Makefile`):
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ engine/
|
|||||||
scripts/ run.sh and the user.properties template.
|
scripts/ run.sh and the user.properties template.
|
||||||
Makefile
|
Makefile
|
||||||
dist/ Build output (gitignored) — see below.
|
dist/ Build output (gitignored) — see below.
|
||||||
|
interpreter/ Separate module + binary (dist/interpreter-cli):
|
||||||
|
scores how significant a day's transits are,
|
||||||
|
reading deck-engine's --format json output
|
||||||
|
(see CLAUDE.md).
|
||||||
docs/ Architecture/dataflow diagrams, input/output format reference.
|
docs/ Architecture/dataflow diagrams, input/output format reference.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,7 +88,16 @@ dist/run.sh html de # override the language for this run
|
|||||||
dist/deck-engine --seed "2026-07-03" \
|
dist/deck-engine --seed "2026-07-03" \
|
||||||
--birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
|
--birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
|
||||||
--birth-lat 52.5200 --birth-lon 13.4050 \
|
--birth-lat 52.5200 --birth-lon 13.4050 \
|
||||||
[--date YYYY-MM-DDTHH:MM] [--format text|html] [--lang en|de]
|
[--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interpreter** (`interpreter/`, built separately with `cd interpreter && make`):
|
||||||
|
scores how significant a day's transits are, reading `deck-engine`'s
|
||||||
|
`--format json` output — the two binaries compose over that JSON, they're
|
||||||
|
never linked together:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/deck-engine ... --format json | dist/interpreter-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
See [`architecture.md`](architecture.md) for how these fit together.
|
See [`architecture.md`](architecture.md) for how these fit together.
|
||||||
There are two ways to provide input (direct CLI flags, or `run.sh` +
|
There are two ways to provide input (direct CLI flags, or `run.sh` +
|
||||||
`user.properties`) and two output formats (`text`, `html`); the
|
`user.properties`) and three output formats (`text`, `html`, `json`); the
|
||||||
`DailyReading` struct is the programmatic form both outputs are rendered
|
`DailyReading` struct is the programmatic form all three are rendered
|
||||||
from, and the one the future watchapp will consume directly.
|
from, and the one the future watchapp will consume directly. `json` is
|
||||||
|
also `interpreter-cli`'s input format — see "The interpreter" at the
|
||||||
|
bottom of this file.
|
||||||
|
|
||||||
## Input
|
## Input
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ from, and the one the future watchapp will consume directly.
|
|||||||
```
|
```
|
||||||
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||||
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
||||||
[--date YYYY-MM-DDTHH:MM] [--format text|html]
|
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Required | Format | Meaning |
|
| Flag | Required | Format | Meaning |
|
||||||
@@ -25,7 +27,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
|||||||
| `--birth-lat` | yes | decimal degrees | Birth latitude, north positive. |
|
| `--birth-lat` | yes | decimal degrees | Birth latitude, north positive. |
|
||||||
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
||||||
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
||||||
| `--format` | no | `text` \| `html` | Output format, defaults to `text`. |
|
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
||||||
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
||||||
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
||||||
|
|
||||||
@@ -156,3 +158,93 @@ cards rendered as an image:
|
|||||||
image links.
|
image links.
|
||||||
- Reversed cards get `class="reversed"`, styled with `transform:
|
- Reversed cards get `class="reversed"`, styled with `transform:
|
||||||
rotate(180deg)` in the page's inline CSS.
|
rotate(180deg)` in the page's inline CSS.
|
||||||
|
|
||||||
|
### `--format json`
|
||||||
|
|
||||||
|
A single JSON object mirroring `DailyReading` exactly — `natal`,
|
||||||
|
`transits`, `spread` — using the **slug** accessors
|
||||||
|
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
||||||
|
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
||||||
|
rather than the display-name ones, so the output is identical regardless
|
||||||
|
of `--lang` — this is the one format meant for another program to parse
|
||||||
|
(`dist/interpreter-cli`, see "The interpreter" below), not for a human to
|
||||||
|
read.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"natal": {
|
||||||
|
"bodies": [
|
||||||
|
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"ascendant_longitude": 348.4123,
|
||||||
|
"houses": ["pisces", "aries", ...]
|
||||||
|
},
|
||||||
|
"transits": {
|
||||||
|
"bodies": [
|
||||||
|
{"body": "sun", "sign": "cancer", "degree_in_sign": 11.5012, "ecliptic_longitude": 101.5012, "house": 5},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"moon_phase": "waning_gibbous",
|
||||||
|
"aspects": [
|
||||||
|
{"transiting_planet": "sun", "natal_planet": "moon", "type": "opposition", "orb": 3.7021},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"spread": {
|
||||||
|
"positions": [
|
||||||
|
{"position": "present", "card": "high_priestess", "reversed": true},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
||||||
|
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
||||||
|
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
||||||
|
Foundation, Recent Past, Near Future, Attitude, Environment, Hopes and
|
||||||
|
Fears, Outcome — see `CLAUDE.md`'s "Tarot" section for why that's not
|
||||||
|
the order most tutorials use).
|
||||||
|
- `natal.houses[i]` is the sign occupying whole-sign house `i + 1`;
|
||||||
|
`houses[0]` is the Ascendant's own sign. `transits.bodies[*].house` is
|
||||||
|
a house number (1-12) into the **natal** chart, not a fresh "houses for
|
||||||
|
right now" chart — same rule as the `text`/`html` output.
|
||||||
|
- All angles are decimal degrees, `%.4f`. `aspects` may be an empty array
|
||||||
|
(a valid "no aspects today"), but the key itself is always present.
|
||||||
|
|
||||||
|
## The interpreter (`interpreter/`, `dist/interpreter-cli`)
|
||||||
|
|
||||||
|
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
|
||||||
|
"Interpretation" section for the full architecture — that reads a
|
||||||
|
`--format json` document and scores how significant today's transits
|
||||||
|
are. It never links the engine; it only depends on the JSON shape
|
||||||
|
above.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/deck-engine ... --format json | dist/interpreter-cli
|
||||||
|
# or:
|
||||||
|
dist/deck-engine ... --format json > reading.json
|
||||||
|
dist/interpreter-cli reading.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Input**: a file path argument, or stdin if no argument (or `-`) is
|
||||||
|
given. Only `"transits"."aspects"` is read — `natal` and `spread` may
|
||||||
|
be present (and are ignored) or omitted entirely, except that
|
||||||
|
`"transits"."aspects"` itself must be present (an empty array is valid
|
||||||
|
and means "no notable transits today"; a missing key is treated as
|
||||||
|
invalid input).
|
||||||
|
- **Output**: a plain-text report — the day's overall significance level
|
||||||
|
(`Quiet`/`Notable`/`Significant`/`Major`), a "worth a deeper Celtic
|
||||||
|
Cross look" line on `Major` days only, and up to 5 of today's aspects
|
||||||
|
ranked by score:
|
||||||
|
|
||||||
|
```
|
||||||
|
Day significance: Significant
|
||||||
|
(a major transit today - worth a deeper Celtic Cross look)
|
||||||
|
|
||||||
|
Top 3 significant transits:
|
||||||
|
1. Transiting Saturn Square natal Sun (orb 0.5°, score 8.10)
|
||||||
|
2. Transiting Pluto Opposition natal Moon (orb 0.2°, score 7.92)
|
||||||
|
3. Transiting Jupiter Trine natal Venus (orb 2.1°, score 3.40)
|
||||||
|
```
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
# Your daily reading, explained
|
||||||
|
|
||||||
|
This page describes what actually happens when you get a reading —
|
||||||
|
no code, no jargon you haven't already agreed to. If you just want to
|
||||||
|
know "what am I looking at", start here.
|
||||||
|
|
||||||
|
## The one-time setup: telling it who you are
|
||||||
|
|
||||||
|
Before you can get a personal reading, you tell it a few things about
|
||||||
|
your birth, once:
|
||||||
|
|
||||||
|
- the **date** and **time** you were born, and which time zone that
|
||||||
|
clock time was in
|
||||||
|
- **where** you were born (latitude/longitude)
|
||||||
|
|
||||||
|
That's it. This information never changes and is only used to work out
|
||||||
|
exactly where the planets were, and which zodiac sign was rising, at the
|
||||||
|
moment you were born — your **natal chart**. Everything else the app
|
||||||
|
does is compared against that one fixed reference point.
|
||||||
|
|
||||||
|
## What happens every day
|
||||||
|
|
||||||
|
Each day, the app does two independent things and then combines them:
|
||||||
|
|
||||||
|
1. **It looks at the sky right now** and compares it to your natal
|
||||||
|
chart from step one — this tells you what's changing today,
|
||||||
|
relative to your own personal baseline (not a generic horoscope).
|
||||||
|
2. **It shuffles a tarot deck**, using today's date as the shuffle
|
||||||
|
"seed". This means: if you check your reading three times on the
|
||||||
|
same day, you get the exact same 10 cards, in the same order, every
|
||||||
|
time — it only changes when the date changes. There's no randomness
|
||||||
|
involved beyond that; today's date fully determines today's spread.
|
||||||
|
|
||||||
|
## The three parts of a reading
|
||||||
|
|
||||||
|
### 1. Your natal chart
|
||||||
|
|
||||||
|
A snapshot of the sky at the moment you were born: where the Sun,
|
||||||
|
Moon, and each planet were, which zodiac sign was rising on the
|
||||||
|
eastern horizon at that moment (your **Ascendant**), and which of the
|
||||||
|
12 astrological "houses" each planet falls into as a result. Think of
|
||||||
|
this as your personal baseline — it doesn't change day to day, and
|
||||||
|
every other part of the reading is measured against it.
|
||||||
|
|
||||||
|
### 2. Today's sky (your "transits")
|
||||||
|
|
||||||
|
Where the Sun, Moon, and planets are positioned *today*, plus:
|
||||||
|
|
||||||
|
- the current **Moon phase** (New, Waxing Crescent, Full, and so on)
|
||||||
|
- any **aspects** — meaningful angles formed between where a planet is
|
||||||
|
today and where a planet sat in your natal chart. For example, "the
|
||||||
|
Sun is opposite your natal Moon today" is one aspect. These are the
|
||||||
|
traditional signals astrologers use to say a day is emotionally
|
||||||
|
charged, a good day for decisions, a day of tension, etc. Not every
|
||||||
|
day has aspects; some days are quiet.
|
||||||
|
|
||||||
|
Each planet's position today is also placed into one of *your* 12
|
||||||
|
houses (not a fresh chart for right now) — that's the standard way
|
||||||
|
astrologers read "what house is this transit happening in for me".
|
||||||
|
|
||||||
|
### 3. Your tarot spread (the Celtic Cross)
|
||||||
|
|
||||||
|
A classic 10-card tarot layout, drawn from the 22 Major Arcana cards
|
||||||
|
(The Fool, The Magician, and so on through The World). Each of the 10
|
||||||
|
positions asks a different question about your day or situation — for
|
||||||
|
example, "what covers you right now", "what crosses/challenges you",
|
||||||
|
"your hopes and fears", "the likely outcome". Every card can land
|
||||||
|
**upright** or **reversed**, which traditionally shades or inverts its
|
||||||
|
meaning. The order and meanings of the 10 positions, and the meaning
|
||||||
|
of each card, come from A. E. Waite's original 1911 description of the
|
||||||
|
spread — the same source most modern tarot decks trace back to.
|
||||||
|
|
||||||
|
## How "big" is today?
|
||||||
|
|
||||||
|
Not every day is equally eventful astrologically. A separate,
|
||||||
|
optional step can look at today's aspects (part 2, above) and works
|
||||||
|
out an overall **significance level** for the day — Quiet, Notable,
|
||||||
|
Significant, or Major — based on which planets are involved, how exact
|
||||||
|
the angle is, and whether it touches your Sun or Moon (the two most
|
||||||
|
personally weighted points in a chart). On a genuinely Major day, it
|
||||||
|
also calls out the single most important thing happening, as a hint
|
||||||
|
that it's worth paying closer attention to your tarot spread that day
|
||||||
|
rather than treating it as routine. Quiet days still get a full
|
||||||
|
reading — this step only adds a "pay attention today" flag, it never
|
||||||
|
removes anything.
|
||||||
|
|
||||||
|
## What a day's reading actually gives you
|
||||||
|
|
||||||
|
Put together, one day's reading contains: your natal chart (for
|
||||||
|
reference), today's sky and how it's speaking to your chart, how
|
||||||
|
significant today is overall, and your 10-card Celtic Cross spread for
|
||||||
|
the day. Right now these sit side by side — the astrology tells you
|
||||||
|
*how eventful* today is and *where* to pay attention, and the tarot
|
||||||
|
spread gives you the actual guidance to sit with. They don't yet get
|
||||||
|
woven into a single narrative (e.g. a spread explained *in light of*
|
||||||
|
today's significant transit) — that combined storytelling is the next
|
||||||
|
piece to build, not something you get today.
|
||||||
|
|
||||||
|
*(This page, like that piece, is still a work in progress — expect it
|
||||||
|
to grow as the reading itself does.)*
|
||||||
@@ -231,3 +231,8 @@ const char *astro_moon_phase_name(MoonPhaseName phase) {
|
|||||||
const char *astro_aspect_name(AspectType type) {
|
const char *astro_aspect_name(AspectType type) {
|
||||||
return lookup("aspect.", k_aspect_slug[type], k_aspect_names[type]);
|
return lookup("aspect.", k_aspect_slug[type], k_aspect_names[type]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char *astro_body_slug(Body body) { return k_body_slug[body]; }
|
||||||
|
const char *astro_sign_slug(ZodiacSign sign) { return k_sign_slug[sign]; }
|
||||||
|
const char *astro_moon_phase_slug(MoonPhaseName phase) { return k_moon_phase_slug[phase]; }
|
||||||
|
const char *astro_aspect_slug(AspectType type) { return k_aspect_slug[type]; }
|
||||||
|
|||||||
@@ -104,4 +104,13 @@ const char *astro_sign_name(ZodiacSign sign);
|
|||||||
const char *astro_moon_phase_name(MoonPhaseName phase);
|
const char *astro_moon_phase_name(MoonPhaseName phase);
|
||||||
const char *astro_aspect_name(AspectType type);
|
const char *astro_aspect_name(AspectType type);
|
||||||
|
|
||||||
|
/* Stable, language-independent identifiers (e.g. "sun", "waxing_crescent",
|
||||||
|
* "square") - the same strings i18n.c's lookup keys are built from. Used
|
||||||
|
* for machine-readable output (reading_print_json) that must stay
|
||||||
|
* identical regardless of --lang, unlike astro_*_name() above. */
|
||||||
|
const char *astro_body_slug(Body body);
|
||||||
|
const char *astro_sign_slug(ZodiacSign sign);
|
||||||
|
const char *astro_moon_phase_slug(MoonPhaseName phase);
|
||||||
|
const char *astro_aspect_slug(AspectType type);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+9
-4
@@ -8,13 +8,13 @@
|
|||||||
#include "reading.h"
|
#include "reading.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
|
|
||||||
typedef enum { FORMAT_TEXT, FORMAT_HTML } OutputFormat;
|
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
|
||||||
|
|
||||||
static void print_usage(const char *prog) {
|
static void print_usage(const char *prog) {
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
|
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
|
||||||
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
||||||
" [--date YYYY-MM-DDTHH:MM] [--format text|html]\n"
|
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
||||||
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
||||||
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
||||||
" --birth-date/time Birth date/time in local clock time.\n"
|
" --birth-date/time Birth date/time in local clock time.\n"
|
||||||
@@ -23,7 +23,9 @@ static void print_usage(const char *prog) {
|
|||||||
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
||||||
" --format Output format, defaults to text. html links card art via\n"
|
" --format Output format, defaults to text. html links card art via\n"
|
||||||
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
||||||
" directory that `make images` copies into dist/.\n"
|
" directory that `make images` copies into dist/. json uses\n"
|
||||||
|
" stable, language-independent identifiers (not --lang's\n"
|
||||||
|
" display text) - it's the interpreter/ module's input format.\n"
|
||||||
" --lang Reading language code, defaults to en. Matches a file\n"
|
" --lang Reading language code, defaults to en. Matches a file\n"
|
||||||
" named <code>.lang in --i18n-dir (see engine/i18n/).\n"
|
" named <code>.lang in --i18n-dir (see engine/i18n/).\n"
|
||||||
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
|
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
|
||||||
@@ -128,8 +130,9 @@ int main(int argc, char **argv) {
|
|||||||
OutputFormat format;
|
OutputFormat format;
|
||||||
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
|
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
|
||||||
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
|
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
|
||||||
|
else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON;
|
||||||
else {
|
else {
|
||||||
fprintf(stderr, "Invalid --format, expected text or html\n");
|
fprintf(stderr, "Invalid --format, expected text, html, or json\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +152,8 @@ int main(int argc, char **argv) {
|
|||||||
|
|
||||||
if (format == FORMAT_HTML) {
|
if (format == FORMAT_HTML) {
|
||||||
reading_print_html(&reading, stdout);
|
reading_print_html(&reading, stdout);
|
||||||
|
} else if (format == FORMAT_JSON) {
|
||||||
|
reading_print_json(&reading, stdout);
|
||||||
} else {
|
} else {
|
||||||
reading_print_text(&reading, stdout);
|
reading_print_text(&reading, stdout);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,3 +142,55 @@ void reading_print_html(const DailyReading *r, FILE *out) {
|
|||||||
}
|
}
|
||||||
fprintf(out, "</div>\n</body></html>\n");
|
fprintf(out, "</div>\n</body></html>\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void print_body_position_json(FILE *out, const char *indent, Body body,
|
||||||
|
const PlanetPosition *p) {
|
||||||
|
fprintf(out,
|
||||||
|
"%s{\"body\": \"%s\", \"sign\": \"%s\", \"degree_in_sign\": %.4f, "
|
||||||
|
"\"ecliptic_longitude\": %.4f, \"house\": %d}",
|
||||||
|
indent, astro_body_slug(body), astro_sign_slug(p->sign),
|
||||||
|
p->degree_in_sign, p->ecliptic_longitude, p->house);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reading_print_json(const DailyReading *r, FILE *out) {
|
||||||
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
|
||||||
|
for (int b = 0; b < NUM_BODIES; b++) {
|
||||||
|
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
|
||||||
|
fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : "");
|
||||||
|
}
|
||||||
|
fprintf(out, " ],\n \"ascendant_longitude\": %.4f,\n \"houses\": [",
|
||||||
|
r->natal.ascendant_longitude);
|
||||||
|
for (int h = 0; h < 12; h++) {
|
||||||
|
fprintf(out, "\"%s\"%s", astro_sign_slug(r->natal.houses[h]), h + 1 < 12 ? ", " : "");
|
||||||
|
}
|
||||||
|
fprintf(out, "]\n },\n");
|
||||||
|
|
||||||
|
fprintf(out, " \"transits\": {\n \"bodies\": [\n");
|
||||||
|
for (int b = 0; b < NUM_BODIES; b++) {
|
||||||
|
print_body_position_json(out, " ", (Body)b, &r->transits.bodies[b]);
|
||||||
|
fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : "");
|
||||||
|
}
|
||||||
|
fprintf(out, " ],\n \"moon_phase\": \"%s\",\n \"aspects\": [\n",
|
||||||
|
astro_moon_phase_slug(r->transits.moon_phase));
|
||||||
|
for (int i = 0; i < r->transits.aspect_count; i++) {
|
||||||
|
const Aspect *a = &r->transits.aspects[i];
|
||||||
|
fprintf(out,
|
||||||
|
" {\"transiting_planet\": \"%s\", \"natal_planet\": \"%s\", "
|
||||||
|
"\"type\": \"%s\", \"orb\": %.4f}%s\n",
|
||||||
|
astro_body_slug(a->transiting_planet), astro_body_slug(a->natal_planet),
|
||||||
|
astro_aspect_slug(a->type), a->orb,
|
||||||
|
i + 1 < r->transits.aspect_count ? "," : "");
|
||||||
|
}
|
||||||
|
fprintf(out, " ]\n },\n");
|
||||||
|
|
||||||
|
fprintf(out, " \"spread\": {\n \"positions\": [\n");
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
const TarotDraw *draw = &r->spread.positions[i];
|
||||||
|
fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s}%s\n",
|
||||||
|
tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card),
|
||||||
|
draw->reversed ? "true" : "false", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
|
||||||
|
}
|
||||||
|
fprintf(out, " ]\n }\n}\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,4 +23,10 @@ void reading_generate(const char *tarot_seed, const BirthData *birth,
|
|||||||
void reading_print_text(const DailyReading *r, FILE *out);
|
void reading_print_text(const DailyReading *r, FILE *out);
|
||||||
void reading_print_html(const DailyReading *r, FILE *out);
|
void reading_print_html(const DailyReading *r, FILE *out);
|
||||||
|
|
||||||
|
/* Machine-readable serialization of the full struct, using the
|
||||||
|
* language-independent *_slug() accessors (never astro_*_name()/
|
||||||
|
* tarot_*_name()) so the output is identical regardless of --lang. This
|
||||||
|
* is the interchange format interpreter/ reads - see its json.c. */
|
||||||
|
void reading_print_json(const DailyReading *r, FILE *out);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -70,4 +70,11 @@ const char *tarot_card_meaning(TarotCard card, bool reversed);
|
|||||||
const char *tarot_position_name(CelticCrossPosition position);
|
const char *tarot_position_name(CelticCrossPosition position);
|
||||||
const char *tarot_position_description(CelticCrossPosition position);
|
const char *tarot_position_description(CelticCrossPosition position);
|
||||||
|
|
||||||
|
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
||||||
|
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
||||||
|
* keys are built from. Used for machine-readable output
|
||||||
|
* (reading_print_json) that must stay identical regardless of --lang. */
|
||||||
|
const char *tarot_card_slug(TarotCard card);
|
||||||
|
const char *tarot_position_slug(CelticCrossPosition position);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -259,3 +259,6 @@ const char *tarot_position_name(CelticCrossPosition position) {
|
|||||||
const char *tarot_position_description(CelticCrossPosition position) {
|
const char *tarot_position_description(CelticCrossPosition position) {
|
||||||
return position_field(k_position_slug[position], "desc", k_positions[position].description);
|
return position_field(k_position_slug[position], "desc", k_positions[position].description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
||||||
|
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
||||||
|
|||||||
@@ -94,6 +94,49 @@ static void test_reading_generate_smoke(void) {
|
|||||||
printf("PASS test_reading_generate_smoke\n");
|
printf("PASS test_reading_generate_smoke\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_reading_print_json_shape(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 r;
|
||||||
|
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
|
||||||
|
|
||||||
|
FILE *tmp = tmpfile();
|
||||||
|
assert(tmp);
|
||||||
|
reading_print_json(&r, tmp);
|
||||||
|
|
||||||
|
rewind(tmp);
|
||||||
|
char buf[16384];
|
||||||
|
size_t n = fread(buf, 1, sizeof buf - 1, tmp);
|
||||||
|
buf[n] = '\0';
|
||||||
|
fclose(tmp);
|
||||||
|
|
||||||
|
/* Loose structural checks - not a full JSON validator, just enough to
|
||||||
|
* catch a broken serializer (unbalanced braces, a missing section, or
|
||||||
|
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
||||||
|
* into what must stay language-independent regardless of --lang). */
|
||||||
|
assert(buf[0] == '{');
|
||||||
|
assert(strstr(buf, "\"natal\"") != NULL);
|
||||||
|
assert(strstr(buf, "\"transits\"") != NULL);
|
||||||
|
assert(strstr(buf, "\"spread\"") != NULL);
|
||||||
|
assert(strstr(buf, "\"aspects\"") != NULL);
|
||||||
|
assert(strstr(buf, "\"body\": \"sun\"") != NULL); /* slug, not the display name */
|
||||||
|
assert(strstr(buf, "\"Sun\"") == NULL); /* never a *_name() value */
|
||||||
|
|
||||||
|
int braces = 0;
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
if (buf[i] == '{') braces++;
|
||||||
|
if (buf[i] == '}') braces--;
|
||||||
|
}
|
||||||
|
assert(braces == 0);
|
||||||
|
|
||||||
|
printf("PASS test_reading_print_json_shape\n");
|
||||||
|
}
|
||||||
|
|
||||||
/* Run with cwd == engine/ (as `make test` does), so the shipped
|
/* Run with cwd == engine/ (as `make test` does), so the shipped
|
||||||
* engine/i18n/ .lang files are reachable as i18n/<code>.lang. */
|
* engine/i18n/ .lang files are reachable as i18n/<code>.lang. */
|
||||||
static void test_i18n_fallback_and_translation(void) {
|
static void test_i18n_fallback_and_translation(void) {
|
||||||
@@ -130,6 +173,7 @@ int main(void) {
|
|||||||
test_tarot_determinism();
|
test_tarot_determinism();
|
||||||
test_natal_sun_sign();
|
test_natal_sun_sign();
|
||||||
test_reading_generate_smoke();
|
test_reading_generate_smoke();
|
||||||
|
test_reading_print_json_shape();
|
||||||
test_i18n_fallback_and_translation();
|
test_i18n_fallback_and_translation();
|
||||||
printf("All smoke tests passed.\n");
|
printf("All smoke tests passed.\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
CC ?= cc
|
||||||
|
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
||||||
|
|
||||||
|
SRC_DIR := src
|
||||||
|
TEST_DIR := tests
|
||||||
|
BUILD_DIR := build
|
||||||
|
OBJ_DIR := $(BUILD_DIR)/obj
|
||||||
|
|
||||||
|
# Shares deck-engine's build output directory (see ../engine/Makefile),
|
||||||
|
# so a daily run can find both binaries in one place:
|
||||||
|
# dist/deck-engine ... --format json | dist/interpreter-cli
|
||||||
|
DIST_DIR := ../dist
|
||||||
|
BINARY := $(DIST_DIR)/interpreter-cli
|
||||||
|
|
||||||
|
# Only ever compiles this module's own sources - significance.h/
|
||||||
|
# reading_io.h reach into engine/src/ for struct/enum *definitions*
|
||||||
|
# (plain #includes), but no engine/src/*.c (or the vendored Astronomy
|
||||||
|
# Engine) is compiled or linked here. That's what keeps this module (and
|
||||||
|
# its tests) independent of the engine's actual ephemeris/tarot-draw
|
||||||
|
# implementation - see significance.c's and reading_io.c's own comments
|
||||||
|
# for why each duplicates a small table instead of linking astro.c.
|
||||||
|
LIB_SRCS := $(SRC_DIR)/significance.c $(SRC_DIR)/json.c $(SRC_DIR)/reading_io.c
|
||||||
|
LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(LIB_SRCS))
|
||||||
|
MAIN_OBJ := $(OBJ_DIR)/$(SRC_DIR)/main.o
|
||||||
|
|
||||||
|
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(TEST_DIR)/significance_test.o
|
||||||
|
JSON_TEST_OBJ := $(OBJ_DIR)/$(TEST_DIR)/json_test.o
|
||||||
|
SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test
|
||||||
|
JSON_TEST_BIN := $(BUILD_DIR)/json_test
|
||||||
|
|
||||||
|
.PHONY: all test clean
|
||||||
|
|
||||||
|
all: $(BINARY)
|
||||||
|
|
||||||
|
$(BINARY): $(LIB_OBJS) $(MAIN_OBJ)
|
||||||
|
@mkdir -p $(DIST_DIR)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $^
|
||||||
|
|
||||||
|
test: $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN)
|
||||||
|
./$(SIGNIFICANCE_TEST_BIN)
|
||||||
|
./$(JSON_TEST_BIN)
|
||||||
|
|
||||||
|
$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $^
|
||||||
|
|
||||||
|
$(JSON_TEST_BIN): $(OBJ_DIR)/$(SRC_DIR)/json.o $(OBJ_DIR)/$(SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $^
|
||||||
|
|
||||||
|
$(OBJ_DIR)/%.o: %.c
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
$(CC) $(CFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR) $(BINARY)
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
#include "json.h"
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *text;
|
||||||
|
size_t length;
|
||||||
|
size_t pos;
|
||||||
|
bool error;
|
||||||
|
} Cursor;
|
||||||
|
|
||||||
|
static void skip_whitespace(Cursor *c) {
|
||||||
|
while (c->pos < c->length) {
|
||||||
|
char ch = c->text[c->pos];
|
||||||
|
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') c->pos++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static char peek(Cursor *c) {
|
||||||
|
return c->pos < c->length ? c->text[c->pos] : '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *json_value_new(JsonType type) {
|
||||||
|
JsonValue *v = calloc(1, sizeof(JsonValue));
|
||||||
|
v->type = type;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_value(Cursor *c);
|
||||||
|
|
||||||
|
/* Dynamically-growable byte buffer, used to build a parsed string
|
||||||
|
* (including \uXXXX escapes re-encoded as UTF-8). */
|
||||||
|
typedef struct {
|
||||||
|
char *data;
|
||||||
|
size_t len;
|
||||||
|
size_t cap;
|
||||||
|
} StringBuf;
|
||||||
|
|
||||||
|
static void sbuf_init(StringBuf *b) {
|
||||||
|
b->cap = 32;
|
||||||
|
b->len = 0;
|
||||||
|
b->data = malloc(b->cap);
|
||||||
|
b->data[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sbuf_push(StringBuf *b, char ch) {
|
||||||
|
if (b->len + 2 > b->cap) {
|
||||||
|
b->cap *= 2;
|
||||||
|
b->data = realloc(b->data, b->cap);
|
||||||
|
}
|
||||||
|
b->data[b->len++] = ch;
|
||||||
|
b->data[b->len] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sbuf_push_utf8(StringBuf *b, unsigned int codepoint) {
|
||||||
|
if (codepoint <= 0x7F) {
|
||||||
|
sbuf_push(b, (char)codepoint);
|
||||||
|
} else if (codepoint <= 0x7FF) {
|
||||||
|
sbuf_push(b, (char)(0xC0 | (codepoint >> 6)));
|
||||||
|
sbuf_push(b, (char)(0x80 | (codepoint & 0x3F)));
|
||||||
|
} else {
|
||||||
|
sbuf_push(b, (char)(0xE0 | (codepoint >> 12)));
|
||||||
|
sbuf_push(b, (char)(0x80 | ((codepoint >> 6) & 0x3F)));
|
||||||
|
sbuf_push(b, (char)(0x80 | (codepoint & 0x3F)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int hex_digit(char ch) {
|
||||||
|
if (ch >= '0' && ch <= '9') return ch - '0';
|
||||||
|
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
|
||||||
|
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *parse_string_raw(Cursor *c) {
|
||||||
|
if (peek(c) != '"') {
|
||||||
|
c->error = true;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
c->pos++;
|
||||||
|
|
||||||
|
StringBuf buf;
|
||||||
|
sbuf_init(&buf);
|
||||||
|
|
||||||
|
while (c->pos < c->length) {
|
||||||
|
char ch = c->text[c->pos];
|
||||||
|
if (ch == '"') {
|
||||||
|
c->pos++;
|
||||||
|
return buf.data;
|
||||||
|
}
|
||||||
|
if (ch == '\\') {
|
||||||
|
c->pos++;
|
||||||
|
if (c->pos >= c->length) break;
|
||||||
|
char esc = c->text[c->pos];
|
||||||
|
switch (esc) {
|
||||||
|
case '"': sbuf_push(&buf, '"'); c->pos++; break;
|
||||||
|
case '\\': sbuf_push(&buf, '\\'); c->pos++; break;
|
||||||
|
case '/': sbuf_push(&buf, '/'); c->pos++; break;
|
||||||
|
case 'b': sbuf_push(&buf, '\b'); c->pos++; break;
|
||||||
|
case 'f': sbuf_push(&buf, '\f'); c->pos++; break;
|
||||||
|
case 'n': sbuf_push(&buf, '\n'); c->pos++; break;
|
||||||
|
case 'r': sbuf_push(&buf, '\r'); c->pos++; break;
|
||||||
|
case 't': sbuf_push(&buf, '\t'); c->pos++; break;
|
||||||
|
case 'u': {
|
||||||
|
c->pos++;
|
||||||
|
if (c->pos + 4 > c->length) {
|
||||||
|
c->error = true;
|
||||||
|
free(buf.data);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
unsigned int cp = 0;
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int d = hex_digit(c->text[c->pos + (size_t)i]);
|
||||||
|
if (d < 0) {
|
||||||
|
c->error = true;
|
||||||
|
free(buf.data);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
cp = (cp << 4) | (unsigned int)d;
|
||||||
|
}
|
||||||
|
c->pos += 4;
|
||||||
|
sbuf_push_utf8(&buf, cp);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c->error = true;
|
||||||
|
free(buf.data);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sbuf_push(&buf, ch);
|
||||||
|
c->pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c->error = true;
|
||||||
|
free(buf.data);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_string(Cursor *c) {
|
||||||
|
char *s = parse_string_raw(c);
|
||||||
|
if (!s) return NULL;
|
||||||
|
JsonValue *v = json_value_new(JSON_STRING);
|
||||||
|
v->as.string = s;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_number(Cursor *c) {
|
||||||
|
const char *start = c->text + c->pos;
|
||||||
|
char *end = NULL;
|
||||||
|
double val = strtod(start, &end);
|
||||||
|
if (end == start) {
|
||||||
|
c->error = true;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
c->pos += (size_t)(end - start);
|
||||||
|
JsonValue *v = json_value_new(JSON_NUMBER);
|
||||||
|
v->as.number = val;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool match_literal(Cursor *c, const char *literal) {
|
||||||
|
size_t len = strlen(literal);
|
||||||
|
if (c->pos + len > c->length) return false;
|
||||||
|
if (strncmp(c->text + c->pos, literal, len) != 0) return false;
|
||||||
|
c->pos += len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_array(Cursor *c) {
|
||||||
|
c->pos++; /* consume '[' */
|
||||||
|
JsonValue *v = json_value_new(JSON_ARRAY);
|
||||||
|
int cap = 0;
|
||||||
|
|
||||||
|
skip_whitespace(c);
|
||||||
|
if (peek(c) == ']') {
|
||||||
|
c->pos++;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
skip_whitespace(c);
|
||||||
|
JsonValue *item = parse_value(c);
|
||||||
|
if (!item) {
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v->as.array.count >= cap) {
|
||||||
|
cap = cap == 0 ? 4 : cap * 2;
|
||||||
|
v->as.array.items = realloc(v->as.array.items, (size_t)cap * sizeof(JsonValue *));
|
||||||
|
}
|
||||||
|
v->as.array.items[v->as.array.count++] = item;
|
||||||
|
|
||||||
|
skip_whitespace(c);
|
||||||
|
char ch = peek(c);
|
||||||
|
if (ch == ',') {
|
||||||
|
c->pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == ']') {
|
||||||
|
c->pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
c->error = true;
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_object(Cursor *c) {
|
||||||
|
c->pos++; /* consume '{' */
|
||||||
|
JsonValue *v = json_value_new(JSON_OBJECT);
|
||||||
|
int cap = 0;
|
||||||
|
|
||||||
|
skip_whitespace(c);
|
||||||
|
if (peek(c) == '}') {
|
||||||
|
c->pos++;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
skip_whitespace(c);
|
||||||
|
if (peek(c) != '"') {
|
||||||
|
c->error = true;
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char *key = parse_string_raw(c);
|
||||||
|
if (!key) {
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
skip_whitespace(c);
|
||||||
|
if (peek(c) != ':') {
|
||||||
|
c->error = true;
|
||||||
|
free(key);
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
c->pos++;
|
||||||
|
skip_whitespace(c);
|
||||||
|
|
||||||
|
JsonValue *val = parse_value(c);
|
||||||
|
if (!val) {
|
||||||
|
free(key);
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v->as.object.count >= cap) {
|
||||||
|
cap = cap == 0 ? 4 : cap * 2;
|
||||||
|
v->as.object.keys = realloc(v->as.object.keys, (size_t)cap * sizeof(char *));
|
||||||
|
v->as.object.values = realloc(v->as.object.values, (size_t)cap * sizeof(JsonValue *));
|
||||||
|
}
|
||||||
|
v->as.object.keys[v->as.object.count] = key;
|
||||||
|
v->as.object.values[v->as.object.count] = val;
|
||||||
|
v->as.object.count++;
|
||||||
|
|
||||||
|
skip_whitespace(c);
|
||||||
|
char ch = peek(c);
|
||||||
|
if (ch == ',') {
|
||||||
|
c->pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == '}') {
|
||||||
|
c->pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
c->error = true;
|
||||||
|
json_free(v);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonValue *parse_value(Cursor *c) {
|
||||||
|
skip_whitespace(c);
|
||||||
|
char ch = peek(c);
|
||||||
|
if (ch == '{') return parse_object(c);
|
||||||
|
if (ch == '[') return parse_array(c);
|
||||||
|
if (ch == '"') return parse_string(c);
|
||||||
|
if (ch == '-' || (ch >= '0' && ch <= '9')) return parse_number(c);
|
||||||
|
if (match_literal(c, "true")) {
|
||||||
|
JsonValue *v = json_value_new(JSON_BOOL);
|
||||||
|
v->as.boolean = true;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (match_literal(c, "false")) {
|
||||||
|
JsonValue *v = json_value_new(JSON_BOOL);
|
||||||
|
v->as.boolean = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (match_literal(c, "null")) return json_value_new(JSON_NULL);
|
||||||
|
c->error = true;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonValue *json_parse(const char *text, size_t length) {
|
||||||
|
Cursor c = {text, length, 0, false};
|
||||||
|
JsonValue *root = parse_value(&c);
|
||||||
|
if (!root) return NULL;
|
||||||
|
|
||||||
|
skip_whitespace(&c);
|
||||||
|
if (c.pos != c.length) {
|
||||||
|
json_free(root);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
void json_free(JsonValue *value) {
|
||||||
|
if (!value) return;
|
||||||
|
switch (value->type) {
|
||||||
|
case JSON_STRING:
|
||||||
|
free(value->as.string);
|
||||||
|
break;
|
||||||
|
case JSON_ARRAY:
|
||||||
|
for (int i = 0; i < value->as.array.count; i++) json_free(value->as.array.items[i]);
|
||||||
|
free(value->as.array.items);
|
||||||
|
break;
|
||||||
|
case JSON_OBJECT:
|
||||||
|
for (int i = 0; i < value->as.object.count; i++) {
|
||||||
|
free(value->as.object.keys[i]);
|
||||||
|
json_free(value->as.object.values[i]);
|
||||||
|
}
|
||||||
|
free(value->as.object.keys);
|
||||||
|
free(value->as.object.values);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
free(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonValue *json_object_get(const JsonValue *value, const char *key) {
|
||||||
|
if (!value || value->type != JSON_OBJECT) return NULL;
|
||||||
|
for (int i = 0; i < value->as.object.count; i++) {
|
||||||
|
if (strcmp(value->as.object.keys[i], key) == 0) return value->as.object.values[i];
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int json_array_count(const JsonValue *value) {
|
||||||
|
if (!value || value->type != JSON_ARRAY) return 0;
|
||||||
|
return value->as.array.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonValue *json_array_get(const JsonValue *value, int index) {
|
||||||
|
if (!value || value->type != JSON_ARRAY) return NULL;
|
||||||
|
if (index < 0 || index >= value->as.array.count) return NULL;
|
||||||
|
return value->as.array.items[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
double json_as_number(const JsonValue *value) {
|
||||||
|
if (!value || value->type != JSON_NUMBER) return 0.0;
|
||||||
|
return value->as.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *json_as_string(const JsonValue *value) {
|
||||||
|
if (!value || value->type != JSON_STRING) return "";
|
||||||
|
return value->as.string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#ifndef DECK_JSON_H
|
||||||
|
#define DECK_JSON_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* Minimal, hand-rolled JSON reader - just enough to parse deck-engine's
|
||||||
|
* own --format json output (engine/src/reading.c's reading_print_json).
|
||||||
|
* Not a general-purpose/validating JSON library, and unlike the core
|
||||||
|
* engine this is desktop-only (uses malloc) - the interpreter binary was
|
||||||
|
* never meant to run on the watch itself. */
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
JSON_NULL,
|
||||||
|
JSON_BOOL,
|
||||||
|
JSON_NUMBER,
|
||||||
|
JSON_STRING,
|
||||||
|
JSON_ARRAY,
|
||||||
|
JSON_OBJECT
|
||||||
|
} JsonType;
|
||||||
|
|
||||||
|
typedef struct JsonValue JsonValue;
|
||||||
|
|
||||||
|
struct JsonValue {
|
||||||
|
JsonType type;
|
||||||
|
union {
|
||||||
|
bool boolean;
|
||||||
|
double number;
|
||||||
|
char *string;
|
||||||
|
struct { JsonValue **items; int count; } array;
|
||||||
|
struct { char **keys; JsonValue **values; int count; } object;
|
||||||
|
} as;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Parses `text` into a JsonValue tree; `text` must be null-terminated at
|
||||||
|
* text[length] (every caller here reads a whole file/stream into a
|
||||||
|
* malloc'd buffer and NUL-terminates it before calling this). Returns
|
||||||
|
* NULL on malformed input. On success the caller owns the result and
|
||||||
|
* must free it with json_free(). */
|
||||||
|
JsonValue *json_parse(const char *text, size_t length);
|
||||||
|
void json_free(JsonValue *value);
|
||||||
|
|
||||||
|
/* NULL if `key` isn't present or `value` isn't an object. */
|
||||||
|
const JsonValue *json_object_get(const JsonValue *value, const char *key);
|
||||||
|
/* 0 if `value` isn't an array. */
|
||||||
|
int json_array_count(const JsonValue *value);
|
||||||
|
/* NULL if `value` isn't an array or `index` is out of range. */
|
||||||
|
const JsonValue *json_array_get(const JsonValue *value, int index);
|
||||||
|
/* 0.0 / "" if `value` is NULL or the wrong type - callers that need to
|
||||||
|
* distinguish "missing" from "present but zero" should check
|
||||||
|
* json_object_get()'s/json_array_get()'s NULL return first. */
|
||||||
|
double json_as_number(const JsonValue *value);
|
||||||
|
const char *json_as_string(const JsonValue *value);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "reading_io.h"
|
||||||
|
#include "significance.h"
|
||||||
|
|
||||||
|
static void print_usage(const char *prog) {
|
||||||
|
fprintf(stderr,
|
||||||
|
"Usage: %s [reading.json]\n\n"
|
||||||
|
"Reads a DailyReading in deck-engine's --format json shape - from the\n"
|
||||||
|
"given file, or from stdin if no file is given (or it is \"-\") - and\n"
|
||||||
|
"prints a significance report: the day's overall level and the top\n"
|
||||||
|
"aspects driving it.\n\n"
|
||||||
|
"Typical use:\n"
|
||||||
|
" dist/deck-engine ... --format json | %s\n"
|
||||||
|
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
|
||||||
|
prog, prog, prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *read_all(FILE *f, size_t *out_length) {
|
||||||
|
size_t cap = 4096;
|
||||||
|
size_t len = 0;
|
||||||
|
char *buf = malloc(cap);
|
||||||
|
|
||||||
|
size_t n;
|
||||||
|
while ((n = fread(buf + len, 1, cap - len, f)) > 0) {
|
||||||
|
len += n;
|
||||||
|
if (len == cap) {
|
||||||
|
cap *= 2;
|
||||||
|
buf = realloc(buf, cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf = realloc(buf, len + 1);
|
||||||
|
buf[len] = '\0';
|
||||||
|
*out_length = len;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display names for the report - deliberately a small local table rather
|
||||||
|
* than linking engine/src/astro.c's astro_body_name()/astro_aspect_name()
|
||||||
|
* (which would pull in the vendored Astronomy Engine just for cosmetic
|
||||||
|
* text). Same policy as reading_io.c's slug tables. */
|
||||||
|
static const char *body_display_name(Body body) {
|
||||||
|
static const char *const names[NUM_BODIES] = {
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
};
|
||||||
|
return names[body];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *aspect_display_name(AspectType type) {
|
||||||
|
static const char *const names[5] = {
|
||||||
|
"Conjunction", "Sextile", "Square", "Trine", "Opposition",
|
||||||
|
};
|
||||||
|
return names[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *day_level_name(DaySignificance level) {
|
||||||
|
switch (level) {
|
||||||
|
case DAY_QUIET: return "Quiet";
|
||||||
|
case DAY_NOTABLE: return "Notable";
|
||||||
|
case DAY_SIGNIFICANT: return "Significant";
|
||||||
|
case DAY_MAJOR: return "Major";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0))) {
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return argc > 2 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE *in = stdin;
|
||||||
|
bool opened_file = false;
|
||||||
|
if (argc == 2 && strcmp(argv[1], "-") != 0) {
|
||||||
|
in = fopen(argv[1], "r");
|
||||||
|
if (!in) {
|
||||||
|
fprintf(stderr, "error: could not open %s\n", argv[1]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
opened_file = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t length;
|
||||||
|
char *text = read_all(in, &length);
|
||||||
|
if (opened_file) fclose(in);
|
||||||
|
|
||||||
|
DailyReading reading;
|
||||||
|
bool loaded = reading_load_json(text, length, &reading);
|
||||||
|
free(text);
|
||||||
|
if (!loaded) {
|
||||||
|
fprintf(stderr, "error: could not parse input as deck-engine --format json output\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DailyInterpretation interp;
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
printf("Day significance: %s\n", day_level_name(interp.day_level));
|
||||||
|
if (interpretation_deserves_framing(&interp)) {
|
||||||
|
printf("(a major transit today - worth a deeper Celtic Cross look)\n");
|
||||||
|
}
|
||||||
|
printf("\n");
|
||||||
|
|
||||||
|
if (interp.top_item_count == 0) {
|
||||||
|
printf("No notable transits today.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Top %d significant transit%s:\n", interp.top_item_count,
|
||||||
|
interp.top_item_count == 1 ? "" : "s");
|
||||||
|
for (int i = 0; i < interp.top_item_count; i++) {
|
||||||
|
const SignificantItem *item = &interp.top_items[i];
|
||||||
|
printf(" %d. Transiting %s %s natal %s (orb %.1f\xc2\xb0, score %.2f)\n", i + 1,
|
||||||
|
body_display_name(item->aspect.transiting_planet),
|
||||||
|
aspect_display_name(item->aspect.type),
|
||||||
|
body_display_name(item->aspect.natal_planet), item->aspect.orb, item->score);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#include "reading_io.h"
|
||||||
|
#include "json.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
|
||||||
|
* tables), duplicated here rather than linking astro.c into this
|
||||||
|
* otherwise header-only, independently testable module - same policy
|
||||||
|
* and same reasoning as significance.c's max_orb_for_pair(). Keep in
|
||||||
|
* sync if those slugs ever change. */
|
||||||
|
static const char *const k_body_slug[NUM_BODIES] = {
|
||||||
|
"sun", "moon", "mercury", "venus", "mars",
|
||||||
|
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *const k_aspect_slug[5] = {
|
||||||
|
"conjunction", "sextile", "square", "trine", "opposition",
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool body_from_slug(const char *slug, Body *out) {
|
||||||
|
for (int i = 0; i < NUM_BODIES; i++) {
|
||||||
|
if (strcmp(slug, k_body_slug[i]) == 0) {
|
||||||
|
*out = (Body)i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool aspect_type_from_slug(const char *slug, AspectType *out) {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
if (strcmp(slug, k_aspect_slug[i]) == 0) {
|
||||||
|
*out = (AspectType)i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
|
JsonValue *root = json_parse(text, length);
|
||||||
|
if (!root) return false;
|
||||||
|
|
||||||
|
const JsonValue *transits = json_object_get(root, "transits");
|
||||||
|
const JsonValue *aspects = transits ? json_object_get(transits, "aspects") : NULL;
|
||||||
|
if (!aspects) {
|
||||||
|
json_free(root);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = true;
|
||||||
|
int filled = 0;
|
||||||
|
int count = json_array_count(aspects);
|
||||||
|
for (int i = 0; i < count && filled < MAX_ASPECTS; i++) {
|
||||||
|
const JsonValue *item = json_array_get(aspects, i);
|
||||||
|
const char *transiting_slug = json_as_string(json_object_get(item, "transiting_planet"));
|
||||||
|
const char *natal_slug = json_as_string(json_object_get(item, "natal_planet"));
|
||||||
|
const char *type_slug = json_as_string(json_object_get(item, "type"));
|
||||||
|
const JsonValue *orb_value = json_object_get(item, "orb");
|
||||||
|
|
||||||
|
Body transiting, natal;
|
||||||
|
AspectType type;
|
||||||
|
if (!body_from_slug(transiting_slug, &transiting) ||
|
||||||
|
!body_from_slug(natal_slug, &natal) ||
|
||||||
|
!aspect_type_from_slug(type_slug, &type) ||
|
||||||
|
!orb_value) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->transits.aspects[filled].transiting_planet = transiting;
|
||||||
|
out->transits.aspects[filled].natal_planet = natal;
|
||||||
|
out->transits.aspects[filled].type = type;
|
||||||
|
out->transits.aspects[filled].orb = json_as_number(orb_value);
|
||||||
|
filled++;
|
||||||
|
}
|
||||||
|
out->transits.aspect_count = filled;
|
||||||
|
|
||||||
|
json_free(root);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#ifndef DECK_READING_IO_H
|
||||||
|
#define DECK_READING_IO_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* Header-only dependency on the engine, same policy as significance.h -
|
||||||
|
* see its comment for why. */
|
||||||
|
#include "../../engine/src/reading.h"
|
||||||
|
|
||||||
|
/* Parses deck-engine's `--format json` output (text, null-terminated at
|
||||||
|
* text[length]) and fills *out with just enough of a DailyReading for
|
||||||
|
* interpret_daily_reading() to work on: out->transits.aspects[]/
|
||||||
|
* aspect_count. out->natal and out->spread are left zeroed - nothing
|
||||||
|
* here reads them (see significance.c), so there's no reason to parse
|
||||||
|
* the rest of the document yet.
|
||||||
|
*
|
||||||
|
* Returns false on malformed JSON, or if "transits"."aspects" isn't
|
||||||
|
* present as an array (an empty array is fine - that's a real "no
|
||||||
|
* aspects today" reading, not an error). */
|
||||||
|
bool reading_load_json(const char *text, size_t length, DailyReading *out);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#include "significance.h"
|
||||||
|
|
||||||
|
/* Higher = this transiting planet's aspects matter more when they occur.
|
||||||
|
* Slow/outer planets transit rarely, so an aspect from one is a bigger
|
||||||
|
* deal than the same aspect from a fast-moving personal planet - this is
|
||||||
|
* about how rare the *transiting* body's activity is, independent of
|
||||||
|
* which natal point it's touching (see natal_point_bonus for that). */
|
||||||
|
static const double k_transiting_weight[NUM_BODIES] = {
|
||||||
|
[PLANET_SUN] = 2.0,
|
||||||
|
[PLANET_MOON] = 1.0,
|
||||||
|
[PLANET_MERCURY] = 1.5,
|
||||||
|
[PLANET_VENUS] = 1.5,
|
||||||
|
[PLANET_MARS] = 2.0,
|
||||||
|
[PLANET_JUPITER] = 4.0,
|
||||||
|
[PLANET_SATURN] = 6.0,
|
||||||
|
[PLANET_URANUS] = 8.0,
|
||||||
|
[PLANET_NEPTUNE] = 9.0,
|
||||||
|
[PLANET_PLUTO] = 10.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
static double natal_point_bonus(Body natal_planet) {
|
||||||
|
/* A transit to a natal luminary (Sun/Moon) reads as more personally
|
||||||
|
* significant than one to any other planet. */
|
||||||
|
return (natal_planet == PLANET_SUN || natal_planet == PLANET_MOON) ? 1.5 : 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirrors astro.c's own orb_for_pair() (its detection-time orb rule),
|
||||||
|
* duplicated here as a single ternary rather than linking the full
|
||||||
|
* vendored Astronomy Engine into this otherwise header-only,
|
||||||
|
* independently testable module just to reuse one line. Keep this in
|
||||||
|
* sync if that rule in astro.c ever changes. */
|
||||||
|
static double max_orb_for_pair(Body transiting, Body natal) {
|
||||||
|
bool luminary = transiting == PLANET_SUN || transiting == PLANET_MOON ||
|
||||||
|
natal == PLANET_SUN || natal == PLANET_MOON;
|
||||||
|
return luminary ? 8.0 : 6.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double aspect_score(const Aspect *a) {
|
||||||
|
double max_orb = max_orb_for_pair(a->transiting_planet, a->natal_planet);
|
||||||
|
double tightness = 1.0 - (a->orb / max_orb); /* 1.0 = exact, ~0 = at the edge */
|
||||||
|
if (tightness < 0.0) tightness = 0.0;
|
||||||
|
return k_transiting_weight[a->transiting_planet] * tightness *
|
||||||
|
natal_point_bonus(a->natal_planet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inserts (kind, aspect, score) into out->top_items in descending-score
|
||||||
|
* order, keeping only the top MAX_SIGNIFICANT_ITEMS. O(n * 5), fine for
|
||||||
|
* n <= MAX_ASPECTS (100). */
|
||||||
|
static void insert_ranked(DailyInterpretation *out, SignificantItemKind kind,
|
||||||
|
const Aspect *aspect, double score) {
|
||||||
|
int insert_at = out->top_item_count;
|
||||||
|
while (insert_at > 0 && out->top_items[insert_at - 1].score < score) insert_at--;
|
||||||
|
if (insert_at >= MAX_SIGNIFICANT_ITEMS) return;
|
||||||
|
|
||||||
|
int shift_from = out->top_item_count < MAX_SIGNIFICANT_ITEMS
|
||||||
|
? out->top_item_count
|
||||||
|
: MAX_SIGNIFICANT_ITEMS - 1;
|
||||||
|
for (int j = shift_from; j > insert_at; j--) {
|
||||||
|
out->top_items[j] = out->top_items[j - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
out->top_items[insert_at].kind = kind;
|
||||||
|
out->top_items[insert_at].aspect = *aspect;
|
||||||
|
out->top_items[insert_at].score = score;
|
||||||
|
if (out->top_item_count < MAX_SIGNIFICANT_ITEMS) out->top_item_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thresholds are tunable, not derived from anything physical - chosen so
|
||||||
|
* an exact outer-planet-to-luminary hit (the classic "big transit") lands
|
||||||
|
* solidly in DAY_MAJOR while a single loose, minor-planet aspect stays
|
||||||
|
* DAY_QUIET. Adjust here if real usage says the cutoffs feel off. */
|
||||||
|
static DaySignificance classify_day(int top_item_count, double top_score) {
|
||||||
|
if (top_item_count == 0) return DAY_QUIET;
|
||||||
|
if (top_score >= 9.0) return DAY_MAJOR;
|
||||||
|
if (top_score >= 5.0) return DAY_SIGNIFICANT;
|
||||||
|
if (top_score >= 2.5) return DAY_NOTABLE;
|
||||||
|
return DAY_QUIET;
|
||||||
|
}
|
||||||
|
|
||||||
|
void interpret_daily_reading(const DailyReading *reading, DailyInterpretation *out) {
|
||||||
|
out->top_item_count = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < reading->transits.aspect_count; i++) {
|
||||||
|
const Aspect *a = &reading->transits.aspects[i];
|
||||||
|
insert_ranked(out, ITEM_ASPECT, a, aspect_score(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
double top_score = out->top_item_count > 0 ? out->top_items[0].score : 0.0;
|
||||||
|
out->day_level = classify_day(out->top_item_count, top_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool interpretation_deserves_framing(const DailyInterpretation *interp) {
|
||||||
|
return interp->day_level == DAY_MAJOR;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#ifndef DECK_SIGNIFICANCE_H
|
||||||
|
#define DECK_SIGNIFICANCE_H
|
||||||
|
|
||||||
|
/* Header-only dependency on the engine: this module never compiles or
|
||||||
|
* links the engine's own .c files (or the vendored Astronomy Engine),
|
||||||
|
* only shares its struct/enum definitions. That's what makes it testable
|
||||||
|
* with a hand-built DailyReading instead of a real reading_generate()
|
||||||
|
* call - see interpreter/tests/significance_test.c. */
|
||||||
|
#include "../../engine/src/reading.h"
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#define MAX_SIGNIFICANT_ITEMS 5
|
||||||
|
|
||||||
|
/* Only aspects compete for the top-5 today. Kept as an explicit tag
|
||||||
|
* (rather than assuming "item == aspect") so a future kind - e.g. a
|
||||||
|
* notable moon phase or a house ingress - can be added without
|
||||||
|
* reshaping this struct. */
|
||||||
|
typedef enum {
|
||||||
|
ITEM_ASPECT = 0
|
||||||
|
} SignificantItemKind;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
SignificantItemKind kind;
|
||||||
|
Aspect aspect; /* valid when kind == ITEM_ASPECT */
|
||||||
|
double score; /* higher = more significant; no fixed upper bound */
|
||||||
|
} SignificantItem;
|
||||||
|
|
||||||
|
/* Overall classification of the day, derived from the top item's score.
|
||||||
|
* A small enum, not a raw number, so callers (rendering, i18n, "does
|
||||||
|
* this deserve Celtic Cross framing") don't each have to invent their
|
||||||
|
* own thresholds. */
|
||||||
|
typedef enum {
|
||||||
|
DAY_QUIET = 0,
|
||||||
|
DAY_NOTABLE,
|
||||||
|
DAY_SIGNIFICANT,
|
||||||
|
DAY_MAJOR
|
||||||
|
} DaySignificance;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
SignificantItem top_items[MAX_SIGNIFICANT_ITEMS];
|
||||||
|
int top_item_count; /* <= MAX_SIGNIFICANT_ITEMS; fewer if the day had
|
||||||
|
* fewer than 5 aspects in orb */
|
||||||
|
DaySignificance day_level;
|
||||||
|
} DailyInterpretation;
|
||||||
|
|
||||||
|
/* Reads reading->transits.aspects[] only - never touches natal/spread,
|
||||||
|
* and never calls back into the engine, so a hand-built DailyReading is
|
||||||
|
* a complete, valid input. */
|
||||||
|
void interpret_daily_reading(const DailyReading *reading, DailyInterpretation *out);
|
||||||
|
|
||||||
|
/* True once day_level reaches the threshold at which the Celtic Cross
|
||||||
|
* reading should get a "this matters" framing sentence naming
|
||||||
|
* interp->top_items[0]. */
|
||||||
|
bool interpretation_deserves_framing(const DailyInterpretation *interp);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "../src/json.h"
|
||||||
|
#include "../src/reading_io.h"
|
||||||
|
|
||||||
|
static void test_json_parse_basic_shapes(void) {
|
||||||
|
const char *text = "{\"a\": 1, \"b\": [true, false, null, \"text\"], \"c\": {\"nested\": 2.5}}";
|
||||||
|
JsonValue *root = json_parse(text, strlen(text));
|
||||||
|
assert(root && root->type == JSON_OBJECT);
|
||||||
|
|
||||||
|
assert(json_as_number(json_object_get(root, "a")) == 1.0);
|
||||||
|
|
||||||
|
const JsonValue *b = json_object_get(root, "b");
|
||||||
|
assert(json_array_count(b) == 4);
|
||||||
|
assert(json_array_get(b, 0)->type == JSON_BOOL && json_array_get(b, 0)->as.boolean == true);
|
||||||
|
assert(json_array_get(b, 1)->type == JSON_BOOL && json_array_get(b, 1)->as.boolean == false);
|
||||||
|
assert(json_array_get(b, 2)->type == JSON_NULL);
|
||||||
|
assert(strcmp(json_as_string(json_array_get(b, 3)), "text") == 0);
|
||||||
|
|
||||||
|
const JsonValue *c = json_object_get(root, "c");
|
||||||
|
assert(json_as_number(json_object_get(c, "nested")) == 2.5);
|
||||||
|
|
||||||
|
json_free(root);
|
||||||
|
printf("PASS test_json_parse_basic_shapes\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_json_parse_rejects_malformed(void) {
|
||||||
|
const char *bad_inputs[] = {
|
||||||
|
"{not valid json",
|
||||||
|
"[1, 2,]",
|
||||||
|
"{\"a\": }",
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(bad_inputs) / sizeof(bad_inputs[0]); i++) {
|
||||||
|
JsonValue *root = json_parse(bad_inputs[i], strlen(bad_inputs[i]));
|
||||||
|
assert(root == NULL);
|
||||||
|
}
|
||||||
|
printf("PASS test_json_parse_rejects_malformed\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real
|
||||||
|
* --format json shape: natal/spread sections are present with decoy
|
||||||
|
* content the loader must skip over without understanding their schema,
|
||||||
|
* to prove it navigates by key path rather than assuming any position. */
|
||||||
|
static const char *k_fixture =
|
||||||
|
"{"
|
||||||
|
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\"}], \"houses\": []},"
|
||||||
|
" \"transits\": {"
|
||||||
|
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\"}],"
|
||||||
|
" \"moon_phase\": \"full\","
|
||||||
|
" \"aspects\": ["
|
||||||
|
" {\"transiting_planet\": \"pluto\", \"natal_planet\": \"moon\", \"type\": \"opposition\", \"orb\": 0.2},"
|
||||||
|
" {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}"
|
||||||
|
" ]"
|
||||||
|
" },"
|
||||||
|
" \"spread\": {\"positions\": [{\"position\": \"present\", \"card\": \"devil\", \"reversed\": false}]}"
|
||||||
|
"}";
|
||||||
|
|
||||||
|
static void test_reading_load_json_extracts_aspects(void) {
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.transits.aspect_count == 2);
|
||||||
|
assert(reading.transits.aspects[0].transiting_planet == PLANET_PLUTO);
|
||||||
|
assert(reading.transits.aspects[0].natal_planet == PLANET_MOON);
|
||||||
|
assert(reading.transits.aspects[0].type == ASPECT_OPPOSITION);
|
||||||
|
assert(reading.transits.aspects[0].orb == 0.2);
|
||||||
|
assert(reading.transits.aspects[1].transiting_planet == PLANET_SATURN);
|
||||||
|
assert(reading.transits.aspects[1].natal_planet == PLANET_SUN);
|
||||||
|
assert(reading.transits.aspects[1].type == ASPECT_SQUARE);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_extracts_aspects\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_empty_aspects_is_valid(void) {
|
||||||
|
const char *text = "{\"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.transits.aspect_count == 0);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_empty_aspects_is_valid\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_rejects_missing_transits(void) {
|
||||||
|
const char *text = "{\"natal\": {}, \"spread\": {}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(!ok);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_rejects_missing_transits\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_rejects_unknown_slug(void) {
|
||||||
|
const char *text =
|
||||||
|
"{\"transits\": {\"aspects\": ["
|
||||||
|
" {\"transiting_planet\": \"xenu\", \"natal_planet\": \"moon\", \"type\": \"square\", \"orb\": 1.0}"
|
||||||
|
"]}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(!ok);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_rejects_unknown_slug\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_json_parse_basic_shapes();
|
||||||
|
test_json_parse_rejects_malformed();
|
||||||
|
test_reading_load_json_extracts_aspects();
|
||||||
|
test_reading_load_json_empty_aspects_is_valid();
|
||||||
|
test_reading_load_json_rejects_missing_transits();
|
||||||
|
test_reading_load_json_rejects_unknown_slug();
|
||||||
|
printf("All JSON tests passed.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "../src/significance.h"
|
||||||
|
|
||||||
|
static Aspect make_aspect(Body transiting, Body natal, AspectType type, double orb) {
|
||||||
|
Aspect a;
|
||||||
|
a.transiting_planet = transiting;
|
||||||
|
a.natal_planet = natal;
|
||||||
|
a.type = type;
|
||||||
|
a.orb = orb;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_empty_day_is_quiet(void) {
|
||||||
|
DailyReading reading = {0};
|
||||||
|
DailyInterpretation interp;
|
||||||
|
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
assert(interp.top_item_count == 0);
|
||||||
|
assert(interp.day_level == DAY_QUIET);
|
||||||
|
assert(!interpretation_deserves_framing(&interp));
|
||||||
|
|
||||||
|
printf("PASS test_empty_day_is_quiet\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_exact_outer_planet_to_luminary_is_major(void) {
|
||||||
|
DailyReading reading = {0};
|
||||||
|
reading.transits.aspect_count = 1;
|
||||||
|
reading.transits.aspects[0] = make_aspect(PLANET_PLUTO, PLANET_SUN, ASPECT_SQUARE, 0.1);
|
||||||
|
|
||||||
|
DailyInterpretation interp;
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
assert(interp.top_item_count == 1);
|
||||||
|
assert(interp.day_level == DAY_MAJOR);
|
||||||
|
assert(interpretation_deserves_framing(&interp));
|
||||||
|
assert(interp.top_items[0].aspect.transiting_planet == PLANET_PLUTO);
|
||||||
|
|
||||||
|
printf("PASS test_exact_outer_planet_to_luminary_is_major\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_top_five_selected_and_ranked(void) {
|
||||||
|
/* 7 candidates spanning very tight/major to very loose/minor. The two
|
||||||
|
* weakest (a fast Moon transit and a wide-orb Venus-Mars trine, both
|
||||||
|
* with no luminary/slow-planet weight behind them) should be evicted,
|
||||||
|
* and the strongest (near-exact outer-planet opposition to natal Moon)
|
||||||
|
* should end up first. */
|
||||||
|
DailyReading reading = {0};
|
||||||
|
reading.transits.aspect_count = 7;
|
||||||
|
reading.transits.aspects[0] = make_aspect(PLANET_PLUTO, PLANET_MOON, ASPECT_OPPOSITION, 0.2);
|
||||||
|
reading.transits.aspects[1] = make_aspect(PLANET_SATURN, PLANET_SUN, ASPECT_SQUARE, 0.5);
|
||||||
|
reading.transits.aspects[2] = make_aspect(PLANET_URANUS, PLANET_MERCURY, ASPECT_TRINE, 1.0);
|
||||||
|
reading.transits.aspects[3] = make_aspect(PLANET_JUPITER, PLANET_VENUS, ASPECT_SEXTILE, 2.0);
|
||||||
|
reading.transits.aspects[4] = make_aspect(PLANET_MARS, PLANET_JUPITER, ASPECT_CONJUNCTION, 1.5);
|
||||||
|
reading.transits.aspects[5] = make_aspect(PLANET_MOON, PLANET_MERCURY, ASPECT_SEXTILE, 5.9);
|
||||||
|
reading.transits.aspects[6] = make_aspect(PLANET_VENUS, PLANET_MARS, ASPECT_TRINE, 5.8);
|
||||||
|
|
||||||
|
DailyInterpretation interp;
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
assert(interp.top_item_count == 5);
|
||||||
|
|
||||||
|
for (int i = 1; i < interp.top_item_count; i++) {
|
||||||
|
assert(interp.top_items[i - 1].score >= interp.top_items[i].score);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < interp.top_item_count; i++) {
|
||||||
|
Body t = interp.top_items[i].aspect.transiting_planet;
|
||||||
|
Body n = interp.top_items[i].aspect.natal_planet;
|
||||||
|
assert(!(t == PLANET_MOON && n == PLANET_MERCURY));
|
||||||
|
assert(!(t == PLANET_VENUS && n == PLANET_MARS));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(interp.top_items[0].aspect.transiting_planet == PLANET_PLUTO);
|
||||||
|
assert(interp.top_items[0].aspect.natal_planet == PLANET_MOON);
|
||||||
|
|
||||||
|
printf("PASS test_top_five_selected_and_ranked\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fewer_than_five_aspects_reports_actual_count(void) {
|
||||||
|
DailyReading reading = {0};
|
||||||
|
reading.transits.aspect_count = 2;
|
||||||
|
reading.transits.aspects[0] = make_aspect(PLANET_VENUS, PLANET_MARS, ASPECT_TRINE, 3.0);
|
||||||
|
reading.transits.aspects[1] = make_aspect(PLANET_MERCURY, PLANET_VENUS, ASPECT_SEXTILE, 4.0);
|
||||||
|
|
||||||
|
DailyInterpretation interp;
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
assert(interp.top_item_count == 2);
|
||||||
|
|
||||||
|
printf("PASS test_fewer_than_five_aspects_reports_actual_count\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_empty_day_is_quiet();
|
||||||
|
test_exact_outer_planet_to_luminary_is_major();
|
||||||
|
test_top_five_selected_and_ranked();
|
||||||
|
test_fewer_than_five_aspects_reports_actual_count();
|
||||||
|
printf("All significance tests passed.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
Reference in New Issue
Block a user