diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index a5e4fba..bb1ead5 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -21,6 +21,11 @@ jobs: - name: Check out repository uses: https://gitea.com/actions/checkout@v4 + - name: Test standalone Bash CLI + run: | + bash -n main.sh tests/test_main.sh + bash tests/test_main.sh + - name: Lint run: ruff check . diff --git a/README.md b/README.md index f8271c6..1cbb382 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,43 @@ Tests mock both external services and never send real messages. Historical state from the previous implementation is retained at `archive/blackouts-1404.json` for reference only and is not imported. + +## Standalone outage dashboard + +`main.sh` is a read-only terminal dashboard for the outage state committed to +this public repository. It downloads the latest `state/outages.json` directly +from Gitea, so it can be copied and run without cloning the project or installing +the Python application. + +It requires Bash, `curl`, `jq`, and GNU `date`: + +```sh +./main.sh +``` + +The display uses a flicker-free alternate terminal screen and runs until `q` is +pressed (or it is interrupted with `Ctrl+C`). It updates the countdown every +second and refetches the committed state every 10 minutes. Set a different whole +minute interval with `-i`/`--interval`: + +```sh +./main.sh --interval 5 +``` + +The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the outage +currently in progress or the nearest upcoming outage. A compact schedule below it +lists all active current and future events, including emergency outages. The +terminal output uses English-only labels and omits the stored address and reason +text. When there are no qualifying events it prints an `ALL CLEAR` screen and exits +successfully. + +Colors are enabled only when output is attached to a terminal. Set `NO_COLOR` to +disable them explicitly, or set `COLUMNS` to control the rendered width: + +```sh +NO_COLOR=1 COLUMNS=80 ./main.sh +``` + +When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it +automatically. Otherwise the bundled matching glyphs are used, keeping the same +appearance without making Figlet a dependency. diff --git a/main.sh b/main.sh new file mode 100755 index 0000000..2d5b6ef --- /dev/null +++ b/main.sh @@ -0,0 +1,895 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly STATE_URL="https://mahgit.ir/MeghdadFadaee/daily-blackout-check/raw/branch/main/state/outages.json" +readonly TEHRAN_TZ="Asia/Tehran" + +STATE_PATH="" +DOWNLOAD_PATH="" +REFRESH_STATUS_PATH="" +REFRESH_PID="" +declare -a SCHEDULE=() +HERO="" +REFRESH_ERROR="" +TERMINAL_ACTIVE=0 + +cleanup() { + if ((TERMINAL_ACTIVE)); then + printf '\033[?25h\033[?1049l' + fi + if [[ -n "$REFRESH_PID" ]]; then + kill "$REFRESH_PID" 2>/dev/null || true + wait "$REFRESH_PID" 2>/dev/null || true + fi + if [[ -n "$STATE_PATH" && -f "$STATE_PATH" ]]; then + rm -f -- "$STATE_PATH" + fi + if [[ -n "$DOWNLOAD_PATH" && -f "$DOWNLOAD_PATH" ]]; then + rm -f -- "$DOWNLOAD_PATH" + fi + if [[ -n "$REFRESH_STATUS_PATH" && -f "$REFRESH_STATUS_PATH" ]]; then + rm -f -- "$REFRESH_STATUS_PATH" + fi +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Usage: ./main.sh [-i MINUTES] + +Continuously display current and upcoming electricity outages from the public +repository state. The countdown updates every second. + +Options: + -i, --interval MINUTES Refetch state at this interval (default: 10) + -h, --help Show this help + +Environment: + NO_COLOR Disable ANSI colors when set + COLUMNS Override the detected output width + +Required commands: bash, curl, jq, and GNU date +EOF +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + local command_name="$1" + + if ! command -v "$command_name" >/dev/null 2>&1; then + die "missing required command: $command_name" + fi +} + +fetch_state() { + local destination="$1" + local cache_key="$2" + + curl -fsSL --compressed \ + --connect-timeout 10 \ + --max-time 30 \ + -H 'Cache-Control: no-cache' \ + -o "$destination" \ + "${STATE_URL}?cli=${cache_key}" +} + +start_state_refresh() { + local cache_key="$1" + + : >"$REFRESH_STATUS_PATH" + ( + if ! fetch_state "$DOWNLOAD_PATH" "$cache_key" 2>/dev/null; then + printf 'download-error\n' >"$REFRESH_STATUS_PATH" + exit 1 + fi + if ! validate_state "$DOWNLOAD_PATH"; then + printf 'validation-error\n' >"$REFRESH_STATUS_PATH" + exit 1 + fi + printf 'ok\n' >"$REFRESH_STATUS_PATH" + ) & + REFRESH_PID="$!" +} + +finish_state_refresh() { + local result + + if [[ -z "$REFRESH_PID" || ! -s "$REFRESH_STATUS_PATH" ]]; then + return 1 + fi + wait "$REFRESH_PID" 2>/dev/null || true + REFRESH_PID="" + result="$(<"$REFRESH_STATUS_PATH")" + + case "$result" in + ok) + if ! cp -- "$DOWNLOAD_PATH" "$STATE_PATH"; then + REFRESH_ERROR="Could not cache the downloaded state." + return 1 + fi + REFRESH_ERROR="" + return 0 + ;; + validation-error) + REFRESH_ERROR="Downloaded state is malformed; showing the last valid state." + ;; + *) + REFRESH_ERROR="State refresh failed; showing the last valid state." + ;; + esac + + return 1 +} + +validate_state() { + local path="$1" + + jq -e ' + type == "object" + and .schema_version == 1 + and (.bills | type == "object") + and all( + .bills | to_entries[]; + (.key | type == "string") + and (.value | type == "object") + and (.value.events | type == "object") + and all( + .value.events | to_entries[]; + (.key | type == "string") + and (.value | type == "object") + and (.value.status == "active" or .value.status == "cancelled") + and (.value.snapshot | type == "object") + and (.value.snapshot.outage_number | type == "string") + and .value.snapshot.outage_number == .key + and (.value.snapshot.outage_date | type == "string") + and (.value.snapshot.start_time | type == "string") + and (.value.snapshot.stop_time | type == "string") + and (.value.snapshot.address | type == "string") + and (.value.snapshot.reason | type == "string") + and (.value.snapshot.is_planned | type == "boolean") + ) + ) + ' "$path" >/dev/null 2>&1 +} + +# Convert a Jalali YYYY/MM/DD date to Gregorian YYYY-MM-DD. This arithmetic +# conversion covers the modern Jalali range used by the outage service. +jalali_to_gregorian() { + local input="$1" + local jy jm jd days gy gd gm leap index + local -a month_days=(0 31 28 31 30 31 30 31 31 30 31 30 31) + + if [[ ! "$input" =~ ^([0-9]{4})/([0-9]{2})/([0-9]{2})$ ]]; then + return 1 + fi + + jy=$((10#${BASH_REMATCH[1]})) + jm=$((10#${BASH_REMATCH[2]})) + jd=$((10#${BASH_REMATCH[3]})) + if ((jy < 1 || jm < 1 || jm > 12 || jd < 1)); then + return 1 + fi + if ((jm <= 6 && jd > 31)); then + return 1 + fi + if ((jm >= 7 && jd > 30)); then + return 1 + fi + + jy=$((jy + 1595)) + days=$((-355668 + (365 * jy) + ((jy / 33) * 8) + (((jy % 33) + 3) / 4) + jd)) + if ((jm < 7)); then + days=$((days + ((jm - 1) * 31))) + else + days=$((days + ((jm - 7) * 30) + 186)) + fi + + gy=$((400 * (days / 146097))) + days=$((days % 146097)) + if ((days > 36524)); then + days=$((days - 1)) + gy=$((gy + (100 * (days / 36524)))) + days=$((days % 36524)) + if ((days >= 365)); then + days=$((days + 1)) + fi + fi + + gy=$((gy + (4 * (days / 1461)))) + days=$((days % 1461)) + if ((days > 365)); then + gy=$((gy + ((days - 1) / 365))) + days=$(((days - 1) % 365)) + fi + + gd=$((days + 1)) + leap=0 + if (((gy % 4 == 0 && gy % 100 != 0) || gy % 400 == 0)); then + leap=1 + fi + month_days[2]=$((28 + leap)) + + gm=1 + for index in {1..12}; do + if ((gd <= month_days[index])); then + gm=$index + break + fi + gd=$((gd - month_days[index])) + done + + printf '%04d-%02d-%02d\n' "$gy" "$gm" "$gd" +} + +event_epochs() { + local outage_date="$1" + local start_time="$2" + local stop_time="$3" + local gregorian start_epoch stop_epoch + + if [[ ! "$start_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \ + || ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then + return 1 + fi + if [[ ! "$stop_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \ + || ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then + return 1 + fi + + gregorian="$(jalali_to_gregorian "$outage_date")" || return 1 + start_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $start_time:00" +%s 2>/dev/null)" \ + || return 1 + stop_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $stop_time:00" +%s 2>/dev/null)" \ + || return 1 + if ((stop_epoch <= start_epoch)); then + stop_epoch=$((stop_epoch + 86400)) + fi + + printf '%s %s\n' "$start_epoch" "$stop_epoch" +} + +event_records() { + local path="$1" + + jq -c ' + .bills + | to_entries[] as $bill + | $bill.value.events + | to_entries[] + | { + bill_id: $bill.key, + outage_number: .key, + status: .value.status, + outage_date: .value.snapshot.outage_date, + start_time: .value.snapshot.start_time, + stop_time: .value.snapshot.stop_time, + address: .value.snapshot.address, + reason: .value.snapshot.reason, + is_planned: .value.snapshot.is_planned + } + ' "$path" +} + +record_fields() { + local record="$1" + local -n output="$2" + + mapfile -d '' -t output < <( + jq -j ' + [ + .bill_id, + .outage_number, + .status, + .outage_date, + .start_time, + .stop_time, + .address, + .reason, + (.is_planned | tostring) + ][] | ., "\u0000" + ' <<<"$record" + ) +} + +build_schedule() { + local path="$1" + local now_epoch="$2" + local record start_epoch stop_epoch phase phase_order sort_key line hero_key="" + local -a fields sortable=() + + SCHEDULE=() + HERO="" + + while IFS= read -r record; do + record_fields "$record" fields + if [[ "${fields[2]}" != "active" ]]; then + continue + fi + if ! read -r start_epoch stop_epoch < <( + event_epochs "${fields[3]}" "${fields[4]}" "${fields[5]}" + ); then + printf 'Invalid outage date or time for event %s\n' "${fields[1]}" >&2 + return 1 + fi + if ((stop_epoch <= now_epoch)); then + continue + fi + + if ((start_epoch <= now_epoch)); then + phase="ONGOING" + phase_order=0 + sort_key="$(printf '0:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")" + if [[ -z "$hero_key" || "$stop_epoch" -lt "$hero_key" ]]; then + hero_key="$stop_epoch" + HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" + fi + else + phase="UPCOMING" + phase_order=1 + sort_key="$(printf '1:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")" + if [[ -z "$hero_key" && -z "$HERO" ]]; then + HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" + elif [[ "${HERO%%$'\t'*}" == "UPCOMING" ]]; then + local current_hero_start + current_hero_start="${HERO#*$'\t'}" + current_hero_start="${current_hero_start%%$'\t'*}" + if ((start_epoch < current_hero_start)); then + HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" + fi + fi + fi + line="$sort_key"$'\t'"$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" + sortable+=("$line") + done < <(event_records "$path") + + if ((${#sortable[@]})); then + mapfile -t SCHEDULE < <(printf '%s\n' "${sortable[@]}" | LC_ALL=C sort) + fi +} + +terminal_columns() { + local columns="${COLUMNS:-}" + + if [[ -z "$columns" && -t 1 ]] && command -v tput >/dev/null 2>&1; then + columns="$(tput cols 2>/dev/null || true)" + fi + + if [[ ! "$columns" =~ ^[0-9]+$ || "$columns" -lt 40 ]]; then + columns=80 + fi + printf '%s\n' "$columns" +} + +color_setup() { + RESET="" + BOLD="" + DIM="" + CYAN="" + GREEN="" + YELLOW="" + RED="" + + if ((TERMINAL_ACTIVE)) && [[ -z "${NO_COLOR+x}" ]]; then + RESET=$'\033[0m' + BOLD=$'\033[1m' + DIM=$'\033[2m' + CYAN=$'\033[36m' + GREEN=$'\033[32m' + YELLOW=$'\033[33m' + RED=$'\033[31m' + fi +} + +center_line() { + local text="$1" + local columns="$2" + local padding=0 + + if ((${#text} < columns)); then + padding=$(((columns - ${#text}) / 2)) + fi + printf '%*s%s\n' "$padding" '' "$text" +} + +repeat_character() { + local character="$1" + local count="$2" + local output="" + + printf -v output '%*s' "$count" '' + printf '%s' "${output// /$character}" +} + +large_glyph() { + case "$1" in + 0) + cat <<'EOF' + ,a8888a, + ,8P"' `"Y8, +,8P Y8, +88 88 +88 88 +`8b d8' + `8ba, ,ad8' + "Y8888P" +EOF + ;; + 1) + cat <<'EOF' + 88 + ,d88 +888888 + 88 + 88 + 88 + 88 + 88 +EOF + ;; + 2) + cat <<'EOF' + ad888888b, +d8" "88 + a8P + ,d8P" + a8P" + a8P' +d8" +88888888888 +EOF + ;; + 3) + cat <<'EOF' + ad888888b, +d8" "88 + a8P + aad8" + ""Y8, + "8b +Y8, a88 + "Y888888P' +EOF + ;; + 4) + cat <<'EOF' + ,d8 + ,d888 + ,d8" 88 + ,d8" 88 +,d8" 88 +8888888888888 + 88 + 88 +EOF + ;; + 5) + cat <<'EOF' +8888888888 +88 +88 ____ +88a8PPPP8b, +PP" `8b + d8 +Y8a a8P + "Y88888P" +EOF + ;; + 6) + cat <<'EOF' + ad8888ba, + 8P' "Y8 +d8 +88,dd888bb, +88P' `8b +88 d8 +88a a8P + "Y88888P" +EOF + ;; + 7) + cat <<'EOF' +888888888888 + ,8P' + d8" + ,8P' + d8" + ,8P' + d8" +8P' +EOF + ;; + 8) + cat <<'EOF' + ad88888ba +d8" "8b +Y8a a8P + "Y8aaa8P" + ,d8"""8b, +d8" "8b +Y8a a8P + "Y88888P" +EOF + ;; + 9) + cat <<'EOF' + ad88888ba +d8" "88 +8P 88 +Y8, ,d88 + "PPPPPP"88 + 8P +8b, a8P +`"Y8888P' +EOF + ;; + D) + cat <<'EOF' +88888888ba +88 "8b +88 8b +88 88 +88 88 +88 8P +88 a8P +88888888P" +EOF + ;; + H) + cat <<'EOF' +88 88 +88 88 +88 88 +8888888888 +88 88 +88 88 +88 88 +88 88 +EOF + ;; + M) + cat <<'EOF' +88b d88 +888b d888 +88`8b d8'88 +88 `8b d8' 88 +88 `8b d8' 88 +88 `8b d8' 88 +88 `888' 88 +88 `8' 88 +EOF + ;; + :) + printf '%s\n' ' ' '888' '888' ' ' ' ' '888' '888' ' ' + ;; + ' ') + printf '%s\n' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' + ;; + *) + printf '%s\n' '???' '???' '???' '???' '???' '???' '???' '???' + ;; + esac +} + +print_large_countdown() { + local text="$1" + local columns="$2" + local rendered_banner + local character glyph_line glyph_width index row rendered_width=0 block_padding=0 + local -a glyph_lines rows=('' '' '' '' '' '' '' '') + + if command -v pyfiglet >/dev/null 2>&1; then + if rendered_banner="$(pyfiglet -f univers -w "$columns" -j center "$text" 2>/dev/null)"; then + printf '%s\n' "$rendered_banner" + return + fi + fi + if command -v figlet >/dev/null 2>&1; then + if rendered_banner="$(figlet -f univers -w "$columns" -c "$text" 2>/dev/null)"; then + printf '%s\n' "$rendered_banner" + return + fi + fi + + for ((index = 0; index < ${#text}; index++)); do + character="${text:index:1}" + mapfile -t glyph_lines < <(large_glyph "$character") + glyph_width=0 + for row in {0..7}; do + if ((${#glyph_lines[row]} > glyph_width)); then + glyph_width="${#glyph_lines[row]}" + fi + done + for row in {0..7}; do + printf -v glyph_line '%-*s' "$glyph_width" "${glyph_lines[row]:-}" + rows[row]+="$glyph_line " + done + done + + for row in {0..7}; do + while [[ "${rows[row]}" == *' ' ]]; do + rows[row]="${rows[row]% }" + done + if ((${#rows[row]} > rendered_width)); then + rendered_width="${#rows[row]}" + fi + done + + if ((rendered_width > columns)); then + center_line "$text" "$columns" + return + fi + + block_padding=$(((columns - rendered_width) / 2)) + for row in {0..7}; do + printf '%*s%s\n' "$block_padding" '' "${rows[row]}" + done +} + +duration_parts() { + local seconds="$1" + local minutes days hours + + ((seconds < 0)) && seconds=0 + minutes=$(((seconds + 59) / 60)) + days=$((minutes / 1440)) + hours=$(((minutes % 1440) / 60)) + minutes=$((minutes % 60)) + printf '%s %s %s\n' "$days" "$hours" "$minutes" +} + +banner_duration() { + local seconds="$1" + local minutes hours + + ((seconds < 0)) && seconds=0 + hours=$((seconds / 3600)) + minutes=$(((seconds % 3600) / 60)) + seconds=$((seconds % 60)) + printf '%02d : %02d : %02d\n' "$hours" "$minutes" "$seconds" +} + +human_duration() { + local seconds="$1" + local days hours minutes output="" + + read -r days hours minutes < <(duration_parts "$seconds") + ((days)) && output+="${days}d " + ((hours)) && output+="${hours}h " + output+="${minutes}m" + printf '%s\n' "$output" +} + +render_event() { + local line="$1" + local now_epoch="$2" + local ignored phase start_epoch stop_epoch record type_label timing + local -a fields + + IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line" + record_fields "$record" fields + type_label="EMERGENCY" + [[ "${fields[8]}" == "true" ]] && type_label="PLANNED" + + if [[ "$phase" == "ONGOING" ]]; then + timing="ends in $(human_duration "$((stop_epoch - now_epoch))")" + else + timing="starts in $(human_duration "$((start_epoch - now_epoch))")" + fi + + printf '%s%s%-10s%s %s%-9s%s %s\n' \ + "$BOLD" "$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \ + "$phase" "$RESET" "$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \ + "$type_label" "$RESET" "$timing" + printf ' Date %s %s - %s\n' "${fields[3]}" "${fields[4]}" "${fields[5]}" + printf ' Reference bill %s / outage %s\n' "${fields[0]}" "${fields[1]}" +} + +render_empty() { + local columns="$1" + local width=$((columns < 64 ? columns : 64)) + local rule + + rule="$(repeat_character '=' "$width")" + printf '%s%s%s\n' "$GREEN" "$rule" "$RESET" + printf '\n' + center_line "ALL CLEAR" "$width" + center_line "No current or upcoming outages." "$width" + printf '\n' + printf '%s%s%s\n' "$GREEN" "$rule" "$RESET" +} + +render_unavailable() { + local columns width rule + + color_setup + columns="$(terminal_columns)" + width=$((columns < 64 ? columns : 64)) + rule="$(repeat_character '=' "$width")" + printf '%s%s%s\n\n' "$RED" "$rule" "$RESET" + center_line "STATE UNAVAILABLE" "$width" + center_line "Waiting for the next refresh." "$width" + printf '\n%s%s%s\n' "$RED" "$rule" "$RESET" +} + +start_terminal() { + if [[ -t 1 ]]; then + TERMINAL_ACTIVE=1 + printf '\033[?1049h\033[?25l\033[H' + fi +} + +draw_frame() { + local frame="$1" + local cleared_frame + + if ((TERMINAL_ACTIVE)); then + cleared_frame="${frame//$'\n'/$'\033[K\n'}" + printf '\033[H%s\033[K\n\033[J' "$cleared_frame" + else + printf '%s\n' "$frame" + fi +} + +schedule_next_transition() { + local now_epoch="$1" + local line ignored phase start_epoch stop_epoch record candidate=0 + + for line in "${SCHEDULE[@]}"; do + IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line" + if ((start_epoch > now_epoch)) && ((candidate == 0 || start_epoch < candidate)); then + candidate="$start_epoch" + fi + if ((stop_epoch > now_epoch)) && ((candidate == 0 || stop_epoch < candidate)); then + candidate="$stop_epoch" + fi + done + printf '%s\n' "$candidate" +} + +render_dashboard() { + local now_epoch="$1" + local columns width rule phase start_epoch stop_epoch record remaining banner + local schedule_line ignored + + color_setup + columns="$(terminal_columns)" + width=$((columns < 150 ? columns : 150)) + + if ((${#SCHEDULE[@]} == 0)); then + render_empty "$columns" + return + fi + + IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO" + if [[ "$phase" == "ONGOING" ]]; then + remaining=$((stop_epoch - now_epoch)) + else + remaining=$((start_epoch - now_epoch)) + fi + banner="$(banner_duration "$remaining")" + rule="$(repeat_character '=' "$width")" + + printf '%s%s%s\n' "$CYAN" "$rule" "$RESET" + printf '\n' + printf '%s%s' "$BOLD" "$YELLOW" + print_large_countdown "$banner" "$width" + printf '%s' "$RESET" + printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET" + printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET" + + for schedule_line in "${SCHEDULE[@]}"; do + IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line" + render_event "$schedule_line" "$now_epoch" + printf '%s\n' "$DIM$(repeat_character '-' "$width")$RESET" + done +} + +main() { + local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0 + local last_rendered=-1 next_transition=0 rebuild_schedule=0 frame key + + while (($#)); do + case "$1" in + -h|--help) + usage + return 0 + ;; + -i|--interval) + if (($# < 2)); then + printf 'Error: %s requires a value\n' "$1" >&2 + usage >&2 + return 2 + fi + refresh_minutes="$2" + shift 2 + ;; + --interval=*) + refresh_minutes="${1#*=}" + shift + ;; + *) + printf 'Error: unknown option: %s\n' "$1" >&2 + usage >&2 + return 2 + ;; + esac + done + if [[ ! "$refresh_minutes" =~ ^[0-9]+$ ]] \ + || ((10#$refresh_minutes < 1 || 10#$refresh_minutes > 1440)); then + printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2 + return 2 + fi + refresh_seconds=$((10#$refresh_minutes * 60)) + + require_command curl + require_command jq + require_command date + + STATE_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-state.XXXXXX")" + DOWNLOAD_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-download.XXXXXX")" + REFRESH_STATUS_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-refresh.XXXXXX")" + start_terminal + + while true; do + printf -v now_epoch '%(%s)T' -1 + if ((now_epoch == last_rendered)); then + if [[ -t 0 ]]; then + key="" + if read -rsn 1 -t 0.1 key && [[ "$key" == "q" || "$key" == "Q" ]]; then + break + fi + else + sleep 0.1 + fi + continue + fi + last_rendered="$now_epoch" + + if ((now_epoch >= next_refresh)) && [[ -z "$REFRESH_PID" ]]; then + start_state_refresh "$now_epoch" + next_refresh=$((now_epoch + refresh_seconds)) + fi + if [[ -n "$REFRESH_PID" && -s "$REFRESH_STATUS_PATH" ]]; then + if finish_state_refresh; then + state_ready=1 + rebuild_schedule=1 + fi + fi + + if ((state_ready)); then + if ((rebuild_schedule || (next_transition > 0 && now_epoch >= next_transition))); then + if build_schedule "$STATE_PATH" "$now_epoch"; then + next_transition="$(schedule_next_transition "$now_epoch")" + rebuild_schedule=0 + else + state_ready=0 + REFRESH_ERROR="The cached state contains an invalid date or time." + fi + fi + fi + + frame="$( + if ((state_ready)); then + render_dashboard "$now_epoch" + else + render_unavailable + fi + if [[ -n "$REFRESH_ERROR" ]]; then + printf '\n%s\n' "$REFRESH_ERROR" + fi + if [[ -t 0 ]]; then + printf '\nPress q to quit.\n' + fi + )" + draw_frame "$frame" + done +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tests/test_main.sh b/tests/test_main.sh new file mode 100755 index 0000000..7f6863f --- /dev/null +++ b/tests/test_main.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../main.sh +source "$ROOT_DIR/main.sh" + +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/blackout-cli-tests.XXXXXX")" +trap 'rm -rf -- "$TEST_TMP"' EXIT + +assert_equal() { + local expected="$1" + local actual="$2" + local message="$3" + + if [[ "$expected" != "$actual" ]]; then + printf 'FAIL: %s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_contains() { + local text="$1" + local expected="$2" + local message="$3" + + if [[ "$text" != *"$expected"* ]]; then + printf 'FAIL: %s\nMissing: %s\n' "$message" "$expected" >&2 + exit 1 + fi +} + +FIXTURE="$TEST_TMP/outages.json" +cat >"$FIXTURE" <<'JSON' +{ + "schema_version": 1, + "updated_at": "2026-07-18T16:00:00+03:30", + "bills": { + "222": { + "events": { + "5": { + "status": "active", + "snapshot": { + "outage_number": "5", + "outage_date": "1405/04/28", + "start_time": "09:00", + "stop_time": "11:00", + "address": "Future planned address", + "reason": "Maintenance", + "is_planned": true + } + } + } + }, + "111": { + "events": { + "1": { + "status": "active", + "snapshot": { + "outage_number": "1", + "outage_date": "1405/04/27", + "start_time": "16:00", + "stop_time": "18:00", + "address": "Current address", + "reason": "Load management", + "is_planned": true + } + }, + "2": { + "status": "active", + "snapshot": { + "outage_number": "2", + "outage_date": "1405/04/27", + "start_time": "18:00", + "stop_time": "20:00", + "address": "Emergency address", + "reason": "Emergency load management", + "is_planned": false + } + }, + "3": { + "status": "cancelled", + "snapshot": { + "outage_number": "3", + "outage_date": "1405/04/27", + "start_time": "19:00", + "stop_time": "21:00", + "address": "Cancelled address", + "reason": "Cancelled maintenance", + "is_planned": true + } + }, + "4": { + "status": "active", + "snapshot": { + "outage_number": "4", + "outage_date": "1405/04/27", + "start_time": "10:00", + "stop_time": "11:00", + "address": "Expired address", + "reason": "Old event", + "is_planned": true + } + } + } + } + } +} +JSON + +assert_equal "2026-07-18" "$(jalali_to_gregorian "1405/04/27")" \ + "Jalali dates should convert to Gregorian dates" + +read -r cross_start cross_stop < <(event_epochs "1405/04/27" "23:00" "01:00") +assert_equal "7200" "$((cross_stop - cross_start))" \ + "cross-midnight outages should end on the following day" + +assert_equal "01 : 01 : 01" "$(banner_duration 3661)" \ + "countdowns should include hours, minutes, and seconds" +assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \ + "countdowns should use total hours beyond one day" +FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)" +assert_contains "$FONT_OUTPUT" ',a8888a,' \ + "the countdown should consistently use the univers-style font" + +if ! validate_state "$FIXTURE"; then + printf 'FAIL: valid fixture was rejected\n' >&2 + exit 1 +fi + +NOW_EPOCH="$(TZ=Asia/Tehran date -d '2026-07-18 17:00:00' +%s)" +build_schedule "$FIXTURE" "$NOW_EPOCH" +assert_equal "3" "${#SCHEDULE[@]}" \ + "only active current and future events should be scheduled" +assert_equal "ONGOING" "${HERO%%$'\t'*}" \ + "an ongoing outage should take the hero position" +assert_contains "${SCHEDULE[0]}" '"outage_number":"1"' \ + "ongoing events should sort first" +assert_contains "${SCHEDULE[1]}" '"outage_number":"2"' \ + "the nearest future event should sort second" +assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \ + "later events should remain in the schedule" + +OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")" +if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then + printf 'FAIL: countdown output contains a redundant heading\n' >&2 + exit 1 +fi +assert_contains "$OUTPUT" "PLANNED" \ + "planned events should be labeled" +assert_contains "$OUTPUT" "EMERGENCY" \ + "unplanned events should be labeled" +assert_contains "$OUTPUT" "OUTAGE SCHEDULE (3)" \ + "the dashboard should include every selected event" +if [[ "$OUTPUT" == *"Address"* || "$OUTPUT" == *"Reason"* \ + || "$OUTPUT" == *"Emergency address"* || "$OUTPUT" == *"Load management"* ]]; then + printf 'FAIL: dashboard output exposes address or reason text\n' >&2 + exit 1 +fi +if [[ "$OUTPUT" == *$'\033['* ]]; then + printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2 + exit 1 +fi + +FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)" +build_schedule "$FIXTURE" "$FUTURE_NOW" +assert_equal "UPCOMING" "${HERO%%$'\t'*}" \ + "the nearest future event should be the hero when none are ongoing" + +EMPTY_NOW="$(TZ=Asia/Tehran date -d '2026-07-20 12:00:00' +%s)" +build_schedule "$FIXTURE" "$EMPTY_NOW" +assert_equal "0" "${#SCHEDULE[@]}" \ + "expired schedules should become empty" +EMPTY_OUTPUT="$(NO_COLOR=1 COLUMNS=60 render_dashboard "$EMPTY_NOW")" +assert_contains "$EMPTY_OUTPUT" "ALL CLEAR" \ + "an empty schedule should render the all-clear screen" + +TERMINAL_ACTIVE=1 +DRAW_OUTPUT="$(draw_frame $'short\nlonger')" +TERMINAL_ACTIVE=0 +assert_contains "$DRAW_OUTPUT" $'short\033[K\nlonger\033[K' \ + "interactive redraws should erase old content from every line" + +MALFORMED="$TEST_TMP/malformed.json" +printf '{"schema_version": 999, "bills": {}}\n' >"$MALFORMED" +if validate_state "$MALFORMED"; then + printf 'FAIL: unsupported schema was accepted\n' >&2 + exit 1 +fi + +if ( + curl() { return 22; } + fetch_state "$TEST_TMP/download.json" "123" +); then + printf 'FAIL: download failure was reported as success\n' >&2 + exit 1 +fi + +printf 'Bash CLI tests passed\n'