diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9033c92..eebefd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ jobs: sudo apt-get update sudo apt-get install -y shellcheck bats - - name: Shellcheck install.sh - run: shellcheck install.sh + - name: Shellcheck shell scripts + run: shellcheck ./*.sh - - name: Run install.sh test suite - run: bats tests/install.bats + - name: Run test suite + run: bats tests/*.bats diff --git a/moon.sh b/moon.sh new file mode 100755 index 0000000..0612bc0 --- /dev/null +++ b/moon.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env sh +# moon.sh — print the current phase of the Moon. A tiny, dependency-free +# flourish for agent-ready; deliberately NOT wired into install.sh. +# +# ./moon.sh [EPOCH_SECONDS] +# +# With no argument it uses the current time. Pass a Unix timestamp (whole +# seconds since 1970-01-01 UTC) to ask about any other moment — which is what +# makes it testable and lets you check a date that isn't today. + +set -eu + +# Reference new moon: 2000-01-06 18:14 UTC, as Unix epoch seconds. +KNOWN_NEW_MOON=947182440 +# One synodic month (new moon to new moon): 29.53058867 days, in seconds. +SYNODIC=2551443 + +case "${1:-}" in + -h|--help) echo "usage: $0 [EPOCH_SECONDS]"; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; +esac + +# `date +%s` is a near-universal extension (GNU and BSD alike); there is no +# pure-POSIX way to read the current epoch. An explicit argument sidesteps it. +now="${1:-$(date +%s)}" + +# Reject anything non-numeric up front so the arithmetic below can't misbehave. +case "$now" in + '' | *[!0-9]*) + echo "error: EPOCH_SECONDS must be a non-negative integer: '$now'" >&2 + exit 2 + ;; +esac + +# Seconds elapsed into the current lunar cycle (0 .. SYNODIC-1). The extra +# +SYNODIC guards against a reference-preceding timestamp yielding a negative +# remainder before we normalise it. +elapsed=$(( ( (now - KNOWN_NEW_MOON) % SYNODIC + SYNODIC ) % SYNODIC )) + +# Age of the Moon in whole days since the last new moon. +age=$(( elapsed / 86400 )) + +# Snap to one of eight named phases. The +SYNODIC/2 rounds to the nearest +# segment centre, so a full moon lands on "Full Moon" rather than just past it. +phase=$(( (elapsed * 8 + SYNODIC / 2) / SYNODIC % 8 )) + +case "$phase" in + 0) emoji="🌑"; name="New Moon" ;; + 1) emoji="🌒"; name="Waxing Crescent" ;; + 2) emoji="🌓"; name="First Quarter" ;; + 3) emoji="🌔"; name="Waxing Gibbous" ;; + 4) emoji="🌕"; name="Full Moon" ;; + 5) emoji="🌖"; name="Waning Gibbous" ;; + 6) emoji="🌗"; name="Last Quarter" ;; + 7) emoji="🌘"; name="Waning Crescent" ;; +esac + +printf '%s %s (day %d of ~29.5)\n' "$emoji" "$name" "$age" diff --git a/tests/moon.bats b/tests/moon.bats new file mode 100644 index 0000000..62fa422 --- /dev/null +++ b/tests/moon.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# Exercises moon.sh against fixed timestamps so every phase is deterministic. +# Run with: bats tests/moon.bats +# +# The offsets below are measured from the script's reference new moon +# (2000-01-06 18:14 UTC = epoch 947182440), one per eighth of the lunar cycle. + +setup() { + KIT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + MOON="$KIT/moon.sh" +} + +@test "reference instant reads as a new moon" { + run "$MOON" 947182440 + [ "$status" -eq 0 ] + [[ "$output" == *"🌑"* ]] + [[ "$output" == *"New Moon"* ]] +} + +@test "quarter into the cycle reads as first quarter" { + run "$MOON" 947820300 + [ "$status" -eq 0 ] + [[ "$output" == *"🌓"* ]] + [[ "$output" == *"First Quarter"* ]] +} + +@test "half into the cycle reads as a full moon" { + run "$MOON" 948458161 + [ "$status" -eq 0 ] + [[ "$output" == *"🌕"* ]] + [[ "$output" == *"Full Moon"* ]] +} + +@test "past full but before last quarter reads as waning gibbous" { + run "$MOON" 948777083 + [ "$status" -eq 0 ] + [[ "$output" == *"🌖"* ]] + [[ "$output" == *"Waning Gibbous"* ]] +} + +@test "three quarters into the cycle reads as last quarter" { + run "$MOON" 949096022 + [ "$status" -eq 0 ] + [[ "$output" == *"🌗"* ]] + [[ "$output" == *"Last Quarter"* ]] +} + +@test "reports the moon's age in whole days" { + run "$MOON" 948458161 + [ "$status" -eq 0 ] + [[ "$output" == *"day 14 of ~29.5"* ]] +} + +@test "no argument uses the current time and still succeeds" { + run "$MOON" + [ "$status" -eq 0 ] + [[ "$output" == *"of ~29.5"* ]] +} + +@test "a timestamp before the reference new moon does not crash" { + run "$MOON" 0 + [ "$status" -eq 0 ] + [[ "$output" == *"of ~29.5"* ]] +} + +@test "rejects a non-numeric timestamp" { + run "$MOON" abc + [ "$status" -eq 2 ] + [[ "$output" == *"non-negative integer"* ]] +} + +@test "rejects an unknown option" { + run "$MOON" --nope + [ "$status" -eq 2 ] + [[ "$output" == *"unknown option"* ]] +} + +@test "--help prints usage and exits 0" { + run "$MOON" --help + [ "$status" -eq 0 ] + [[ "$output" == *"usage:"* ]] +} + +@test "works under a strict POSIX sh (dash), not just bash" { + command -v dash >/dev/null 2>&1 || skip "dash not installed" + run dash "$MOON" 948458161 + [ "$status" -eq 0 ] + [[ "$output" == *"Full Moon"* ]] +}