Compare commits

...

11 Commits

Author SHA1 Message Date
gitea-blackout-bot
14f54dd09f chore(state): update outage notification state [skip ci] 2026-07-29 16:01:00 +00:00
gitea-blackout-bot
b75f44f0a1 chore(state): update outage notification state [skip ci] 2026-07-29 14:01:01 +00:00
bf09d4d99c feat(cli): add height-aware compact outage schedule
All checks were successful
Test blackout notifier / test (push) Successful in 5s
Hourly blackout check / check (push) Successful in 5s
2026-07-27 18:03:18 +03:30
0b24d544d3 fix(cli): filter outage schedule by selected bill
All checks were successful
Test blackout notifier / test (push) Successful in 4s
Hourly blackout check / check (push) Successful in 4s
2026-07-27 17:25:38 +03:30
gitea-blackout-bot
b44d6dd3cb chore(state): update outage notification state [skip ci] 2026-07-27 12:01:00 +00:00
gitea-blackout-bot
cfd19fe4a8 chore(state): update outage notification state [skip ci] 2026-07-27 11:04:01 +00:00
gitea-blackout-bot
5f5329530c chore(state): update outage notification state [skip ci] 2026-07-26 13:00:59 +00:00
gitea-blackout-bot
88e11f6594 chore(state): update outage notification state [skip ci] 2026-07-25 13:00:58 +00:00
gitea-blackout-bot
ec54c552c2 chore(state): update outage notification state [skip ci] 2026-07-22 14:00:24 +00:00
gitea-blackout-bot
e9947a14cb chore(state): update outage notification state [skip ci] 2026-07-22 13:00:25 +00:00
Meghdad
ea03d84901 style: apply Ruff formatting
All checks were successful
Test blackout notifier / test (push) Successful in 4s
Hourly blackout check / check (push) Successful in 5s
2026-07-22 03:20:01 +03:30
8 changed files with 364 additions and 59 deletions

View File

@@ -215,7 +215,7 @@ minute interval with `-i`/`--interval`:
./main.sh --interval 5
```
Select one bill for the large countdown with `-b`/`--bill-id`:
Select one bill for the countdown and outage schedule with `-b`/`--bill-id`:
```sh
./main.sh --bill-id 7356609804429
@@ -223,20 +223,25 @@ Select one bill for the large countdown with `-b`/`--bill-id`:
The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the
selected bill's ongoing or nearest upcoming outage. The compact schedule below
continues to list active current and future events for every bill, including
emergency outages. If the selected bill has no qualifying outage, the countdown
area says so while the full schedule remains visible. Without `--bill-id`, the
countdown retains the original behavior and selects across all bills.
is also limited to that bill's active current and future events, including
emergency outages. If the selected bill has no qualifying outage, the dashboard
shows `ALL CLEAR`. Without `--bill-id`, the countdown and schedule include all
bills.
The terminal output uses English-only labels and omits the stored address and
reason text. When there are no qualifying events for any bill it prints an `ALL
CLEAR` screen and exits successfully.
Schedule entries use one compact row each. When every outage cannot fit below
the countdown, the dashboard keeps the nearest entries visible and reports how
many additional outages were omitted. Resizing the terminal reveals more rows.
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:
disable them explicitly, or set `COLUMNS` and `LINES` to control the rendered
size:
```sh
NO_COLOR=1 COLUMNS=80 ./main.sh
NO_COLOR=1 COLUMNS=80 LINES=24 ./main.sh
```
When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it

160
main.sh
View File

@@ -68,12 +68,13 @@ repository state. The countdown updates every second.
Options:
-i, --interval MINUTES Refetch state at this interval (default: 10)
-b, --bill-id BILL_ID Use only this bill for the large countdown
-b, --bill-id BILL_ID Show the countdown and schedule for this bill only
-h, --help Show this help
Environment:
NO_COLOR Disable ANSI colors when set
COLUMNS Override the detected output width
LINES Override the detected output height
Required commands: Bash 4.3+, curl, jq, and GNU date
EOF
@@ -334,6 +335,9 @@ build_schedule() {
while IFS= read -r record; do
record_fields "$record" fields
if [[ -n "$countdown_bill_id" && "${fields[0]}" != "$countdown_bill_id" ]]; then
continue
fi
if [[ "${fields[2]}" != "active" ]]; then
continue
fi
@@ -412,6 +416,38 @@ terminal_columns() {
printf '%s\n' "$columns"
}
terminal_lines() {
local lines="${LINES:-}"
local terminal_size="" detected_lines=""
if [[ -z "$lines" ]] && command -v stty >/dev/null 2>&1; then
if [[ -r /dev/tty ]]; then
terminal_size="$(stty size 2>/dev/null </dev/tty || true)"
fi
if [[ -z "$terminal_size" && -t 2 ]]; then
terminal_size="$(stty size <&2 2>/dev/null || true)"
fi
if [[ -z "$terminal_size" && -t 0 ]]; then
terminal_size="$(stty size <&0 2>/dev/null || true)"
fi
read -r detected_lines _ <<<"$terminal_size"
if [[ "$detected_lines" =~ ^[0-9]+$ ]]; then
lines="$detected_lines"
fi
fi
if [[ -z "$lines" ]] && command -v tput >/dev/null 2>&1; then
lines="$(tput lines 2>/dev/null || true)"
fi
if [[ ! "$lines" =~ ^[0-9]+$ ]]; then
lines=24
elif ((lines < 10)); then
lines=10
fi
printf '%s\n' "$lines"
}
color_setup() {
RESET=""
BOLD=""
@@ -711,10 +747,29 @@ human_duration() {
printf '%s\n' "$output"
}
compact_reference() {
local bill_id="$1"
local outage_number="$2"
local width="$3"
local reference="$bill_id/$outage_number"
local bill_width
if ((${#reference} <= width)); then
printf '%s' "$reference"
elif ((width > ${#outage_number} + 2)); then
bill_width=$((width - ${#outage_number} - 2))
printf '~%s/%s' "${bill_id: -bill_width}" "$outage_number"
elif ((width > 1)); then
printf '%s~' "${reference:0:width-1}"
else
printf '~'
fi
}
render_event() {
local line="$1"
local now_epoch="$2"
local ignored phase start_epoch stop_epoch record type_label timing
local columns="$2"
local ignored phase start_epoch stop_epoch record type_label reference reference_width
local -a fields
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
@@ -722,18 +777,24 @@ render_event() {
type_label="EMERGENCY"
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
if [[ "$phase" == "ONGOING" ]]; then
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
if ((columns >= 60)); then
reference_width=$((columns - 47))
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
printf '%s%-9s%s %s%-9s%s %-10s %-11s %s\n' \
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
"$phase" "$RESET" \
"$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
"$type_label" "$RESET" "${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
else
timing="starts in $(human_duration "$((start_epoch - now_epoch))")"
reference_width=$((columns - 34))
((reference_width < 1)) && reference_width=1
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
printf '%s%-4s%s %-5s %-10s %-11s %s\n' \
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
"$([[ "$phase" == "ONGOING" ]] && printf 'ON' || printf 'UP')" "$RESET" \
"$([[ "$type_label" == "PLANNED" ]] && printf 'PLAN' || printf 'EMERG')" \
"${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
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() {
@@ -777,7 +838,7 @@ draw_frame() {
if ((TERMINAL_ACTIVE)); then
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
printf '\033[H%s\033[K\n\033[J' "$cleared_frame"
printf '\033[H%s\033[K\033[J' "$cleared_frame"
else
printf '%s\n' "$frame"
fi
@@ -803,13 +864,17 @@ render_dashboard() {
local now_epoch="$1"
local columns="${2:-}"
local countdown_bill_id="${3:-}"
local rule phase start_epoch stop_epoch record remaining banner
local schedule_line ignored
local lines="${4:-}"
local rule phase start_epoch stop_epoch record remaining banner banner_art
local banner_rows fixed_rows available_rows visible_count hidden_count index show_bill_heading=0
color_setup
if [[ -z "$columns" ]]; then
columns="$(terminal_columns)"
fi
if [[ -z "$lines" ]]; then
lines="$(terminal_lines)"
fi
if ((${#SCHEDULE[@]} == 0)); then
render_empty "$columns"
@@ -831,29 +896,69 @@ render_dashboard() {
remaining=$((start_epoch - now_epoch))
fi
banner="$(banner_duration "$remaining")"
if [[ -n "$countdown_bill_id" ]]; then
banner_art="$(print_large_countdown "$banner" "$columns")"
banner_rows="$(awk 'END { print NR }' <<<"$banner_art")"
[[ -n "$countdown_bill_id" ]] && show_bill_heading=1
fixed_rows=$((8 + banner_rows))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
if ((fixed_rows + 2 > lines)); then
banner_art="$banner"
banner_rows=1
fixed_rows=$((8 + banner_rows))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
if ((fixed_rows + 1 > lines)); then
show_bill_heading=0
fi
fi
if ((show_bill_heading)); then
center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns"
printf '\n'
fi
printf '%s%s' "$BOLD" "$YELLOW"
print_large_countdown "$banner" "$columns"
printf '%s\n' "$banner_art"
printf '%s' "$RESET"
fi
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
printf '\n%sOUTAGE SCHEDULE (%d)%s\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
if ((columns >= 60)); then
printf '%s%-9s %-9s %-10s %-11s %s%s\n' \
"$BOLD" "STATUS" "TYPE" "DATE" "TIME" "BILL/OUTAGE" "$RESET"
else
printf '%s%-4s %-5s %-10s %-11s %s%s\n' \
"$BOLD" "ST" "TYPE" "DATE" "TIME" "REF" "$RESET"
fi
printf '%s%s%s\n' "$DIM" "$(repeat_character '-' "$columns")" "$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 '-' "$columns")$RESET"
fixed_rows=$((8 + ${banner_rows:-1}))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
available_rows=$((lines - fixed_rows - 1))
((available_rows < 0)) && available_rows=0
visible_count="${#SCHEDULE[@]}"
hidden_count=0
if ((visible_count > available_rows)); then
if ((available_rows > 0)); then
visible_count=$((available_rows - 1))
else
visible_count=0
fi
hidden_count=$((${#SCHEDULE[@]} - visible_count))
fi
for ((index = 0; index < visible_count; index++)); do
render_event "${SCHEDULE[index]}" "$columns"
done
if ((hidden_count && available_rows > 0)); then
printf '%s... %d more outage%s%s\n' \
"$DIM" "$hidden_count" "$([[ "$hidden_count" == 1 ]] || printf 's')" "$RESET"
fi
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
}
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 input_fd=0
local display_columns countdown_bill_id=""
local display_columns display_lines render_lines countdown_bill_id=""
while (($#)); do
case "$1" in
@@ -959,9 +1064,14 @@ main() {
fi
display_columns="$(terminal_columns)"
display_lines="$(terminal_lines)"
render_lines="$display_lines"
if [[ -n "$REFRESH_ERROR" ]] && ((render_lines > 12)); then
render_lines=$((render_lines - 2))
fi
frame="$(
if ((state_ready)); then
render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id"
render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id" "$render_lines"
else
render_unavailable "$display_columns"
fi

View File

@@ -114,14 +114,11 @@ class EitaaClient:
try:
body = response.json()
except requests.JSONDecodeError as error:
raise ExternalServiceError(
f"Eitaa returned invalid JSON for chat {chat_id}"
) from error
raise ExternalServiceError(f"Eitaa returned invalid JSON for chat {chat_id}") from error
if not isinstance(body, dict) or body.get("ok") is not True:
description = body.get("description") if isinstance(body, dict) else None
raise ExternalServiceError(
f"Eitaa rejected the message for chat {chat_id}: "
f"{description or 'unknown error'}"
f"Eitaa rejected the message for chat {chat_id}: {description or 'unknown error'}"
)

View File

@@ -88,16 +88,13 @@ def _bill_chat_map(raw_routes: str) -> dict[str, tuple[str, ...]]:
malformed.append(route)
continue
if not bill_id.isdecimal():
raise ConfigurationError(
f"BILL_CHAT_MAP bill IDs must be decimal: {bill_id}"
)
raise ConfigurationError(f"BILL_CHAT_MAP bill IDs must be decimal: {bill_id}")
chat_ids = routes.setdefault(bill_id, [])
if chat_id not in chat_ids:
chat_ids.append(chat_id)
if malformed:
raise ConfigurationError(
"BILL_CHAT_MAP entries must use BILL_ID:CHAT_ID format: "
+ ", ".join(malformed)
"BILL_CHAT_MAP entries must use BILL_ID:CHAT_ID format: " + ", ".join(malformed)
)
if not routes:
raise ConfigurationError("BILL_CHAT_MAP must contain at least one route")

View File

@@ -91,9 +91,7 @@ class OutageService:
previous = Outage.from_dict(record["snapshot"])
if record["status"] == "cancelled":
if self._notify(
chat_ids, reactivated_outage_message(bill_id, outage, now), result
):
if self._notify(chat_ids, reactivated_outage_message(bill_id, outage, now), result):
records[event_id] = _record(outage, now)
result.state_changed = True
continue
@@ -126,9 +124,7 @@ class OutageService:
if record["missing_checks"] == 0:
record["missing_checks"] = 1
result.state_changed = True
elif self._notify(
chat_ids, cancelled_outage_message(bill_id, previous, now), result
):
elif self._notify(chat_ids, cancelled_outage_message(bill_id, previous, now), result):
record["status"] = "cancelled"
record["missing_checks"] = 2
record["notified_at"] = now.isoformat()

View File

@@ -52,8 +52,8 @@
},
"159719": {
"last_seen_at": "2026-07-21T18:30:24.266070+03:30",
"missing_checks": 0,
"notified_at": "2026-07-21T18:30:24.266070+03:30",
"missing_checks": 2,
"notified_at": "2026-07-22T17:30:23.869053+03:30",
"notified_hash": "40590e173639e2c8ecdb3682c092adfe5a0a9897535df835e4e1ce44cc6afa01",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
@@ -64,11 +64,191 @@
"start_time": "18:00",
"stop_time": "20:00"
},
"status": "cancelled"
},
"159977": {
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
"missing_checks": 0,
"notified_at": "2026-07-22T16:30:24.078355+03:30",
"notified_hash": "9e082e2d029e51980d1178903a346f6d7a0ff9c5036bbc62fd9c256912865e40",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/04/31",
"outage_number": "159977",
"reason": "مدیریت اضطراری بار",
"start_time": "18:15",
"stop_time": "20:00"
},
"status": "active"
},
"160375": {
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
"missing_checks": 0,
"notified_at": "2026-07-22T16:30:24.078355+03:30",
"notified_hash": "1be423abd3e35086d9956597041568a284a8f21ef9a0c67d17cd0e2d83f73cf2",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/05/03",
"outage_number": "160375",
"reason": "مدیریت اضطراری بار",
"start_time": "20:00",
"stop_time": "22:00"
},
"status": "active"
},
"160621": {
"last_seen_at": "2026-07-26T16:30:57.897178+03:30",
"missing_checks": 0,
"notified_at": "2026-07-26T16:30:57.897178+03:30",
"notified_hash": "a1089904ac9932cd248aba8187fa44adabebf926be04e1ae382ef3f93d2a4f4b",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/05/05",
"outage_number": "160621",
"reason": "مدیریت اضطراری بار",
"start_time": "12:00",
"stop_time": "14:00"
},
"status": "active"
},
"160869": {
"last_seen_at": "2026-07-27T15:30:59.279795+03:30",
"missing_checks": 0,
"notified_at": "2026-07-27T15:30:59.279795+03:30",
"notified_hash": "b78a1c2bde240ab2a99bf92e2f51678394c40557d2cf6feb86290cf4b416bf81",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/05/06",
"outage_number": "160869",
"reason": "مدیریت اضطراری بار",
"start_time": "16:00",
"stop_time": "18:00"
},
"status": "active"
},
"161111": {
"last_seen_at": "2026-07-29T17:31:00.393243+03:30",
"missing_checks": 0,
"notified_at": "2026-07-29T17:31:00.393243+03:30",
"notified_hash": "4a19b3095a786c5ce1da5cb41e820ac91bdf59f124c1aad0bc76edc8cfff9b74",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/05/08",
"outage_number": "161111",
"reason": "مدیریت اضطراری بار",
"start_time": "10:00",
"stop_time": "12:00"
},
"status": "active"
},
"161389": {
"last_seen_at": "2026-07-29T19:30:59.469939+03:30",
"missing_checks": 0,
"notified_at": "2026-07-29T19:30:59.469939+03:30",
"notified_hash": "698ad7a1d28d9f1fd42a350926da771a11abd9d5c14d63dbf156f8ca789b80da",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/05/10",
"outage_number": "161389",
"reason": "مدیریت اضطراری بار",
"start_time": "20:00",
"stop_time": "22:00"
},
"status": "active"
}
}
},
"7359439104426": {
"events": {
"160167": {
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
"missing_checks": 0,
"notified_at": "2026-07-22T16:30:24.078355+03:30",
"notified_hash": "b156c680bc077693453f33be3ea11bead797f4793503d151434501b38a552d2d",
"snapshot": {
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
"is_planned": false,
"outage_date": "1405/05/01",
"outage_number": "160167",
"reason": "مدیریت اضطراری بار",
"start_time": "16:00",
"stop_time": "18:00"
},
"status": "active"
},
"160499": {
"last_seen_at": "2026-07-25T16:30:57.756664+03:30",
"missing_checks": 0,
"notified_at": "2026-07-25T16:30:57.756664+03:30",
"notified_hash": "ade1146f40d4ed0bbe8a182afe6f40f0f256a4accd84155fc4ae097e66309734",
"snapshot": {
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
"is_planned": false,
"outage_date": "1405/05/04",
"outage_number": "160499",
"reason": "مدیریت اضطراری بار",
"start_time": "16:00",
"stop_time": "18:00"
},
"status": "active"
},
"160727": {
"last_seen_at": "2026-07-26T16:30:57.897178+03:30",
"missing_checks": 0,
"notified_at": "2026-07-26T16:30:57.897178+03:30",
"notified_hash": "36501deedcf55f8a43dfc42b3ce265097a3a2202a20183f69ff6bc0aaf8f8b58",
"snapshot": {
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
"is_planned": false,
"outage_date": "1405/05/05",
"outage_number": "160727",
"reason": "مدیریت اضطراری بار",
"start_time": "22:00",
"stop_time": "00:00"
},
"status": "active"
},
"160785": {
"last_seen_at": "2026-07-27T14:34:00.319029+03:30",
"missing_checks": 0,
"notified_at": "2026-07-27T14:34:00.319029+03:30",
"notified_hash": "7dbebe0f429ee9591442b43bd91066da407e106f5863d57bfe7e385aaa4ae73e",
"snapshot": {
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
"is_planned": false,
"outage_date": "1405/05/06",
"outage_number": "160785",
"reason": "مدیریت اضطراری بار",
"start_time": "10:00",
"stop_time": "12:00"
},
"status": "active"
},
"161230": {
"last_seen_at": "2026-07-29T17:31:00.393243+03:30",
"missing_checks": 0,
"notified_at": "2026-07-29T17:31:00.393243+03:30",
"notified_hash": "5951e4742eb57490e879f9b7b924460929e750a39dffc9c8fbb00f08fb81a0ae",
"snapshot": {
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
"is_planned": false,
"outage_date": "1405/05/08",
"outage_number": "161230",
"reason": "مدیریت اضطراری بار",
"start_time": "20:00",
"stop_time": "22:00"
},
"status": "active"
}
}
}
},
"schema_version": 1,
"updated_at": "2026-07-21T18:30:24.266070+03:30"
"updated_at": "2026-07-29T19:30:59.469939+03:30"
}

View File

@@ -59,6 +59,7 @@ def test_config_rejects_malformed_routes(
with pytest.raises(ConfigurationError, match="BILL_ID:CHAT_ID"):
Config.from_env()
def test_legacy_single_chat_configuration_is_still_supported(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View File

@@ -120,6 +120,8 @@ 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"
assert_equal "17" "$(LINES=17 terminal_lines)" \
"the terminal height override should be respected"
FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)"
assert_contains "$FONT_OUTPUT" ',a8888a,' \
"the countdown should consistently use the univers-style font"
@@ -143,8 +145,8 @@ assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \
"later events should remain in the schedule"
build_schedule "$FIXTURE" "$NOW_EPOCH" "222"
assert_equal "3" "${#SCHEDULE[@]}" \
"selecting a countdown bill should not filter the schedule"
assert_equal "1" "${#SCHEDULE[@]}" \
"selecting a bill should filter the schedule"
assert_equal "UPCOMING" "${HERO%%$'\t'*}" \
"the countdown should ignore another bill's ongoing outage"
assert_contains "$HERO" '"bill_id":"222"' \
@@ -152,17 +154,21 @@ assert_contains "$HERO" '"bill_id":"222"' \
SELECTED_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "222")"
assert_contains "$SELECTED_OUTPUT" "COUNTDOWN FOR BILL 222" \
"the selected countdown bill should be labeled"
assert_contains "$SELECTED_OUTPUT" "OUTAGE SCHEDULE (3)" \
"the selected countdown bill should not hide other bills from the list"
assert_contains "$SELECTED_OUTPUT" "OUTAGE SCHEDULE (1)" \
"the schedule should contain only the selected bill"
if [[ "$SELECTED_OUTPUT" == *"Bill 111"* ]]; then
printf 'FAIL: selected bill output contains another bill\n' >&2
exit 1
fi
build_schedule "$FIXTURE" "$NOW_EPOCH" "333"
assert_equal "" "$HERO" \
"a bill without upcoming outages should not borrow another bill's countdown"
assert_equal "0" "${#SCHEDULE[@]}" \
"a bill without upcoming outages should have an empty schedule"
NO_HERO_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "333")"
assert_contains "$NO_HERO_OUTPUT" "NO CURRENT OR UPCOMING OUTAGE" \
"a selected bill without outages should have a clear countdown status"
assert_contains "$NO_HERO_OUTPUT" "OUTAGE SCHEDULE (3)" \
"all bills should remain listed when the selected bill has no outage"
assert_contains "$NO_HERO_OUTPUT" "ALL CLEAR" \
"a selected bill without outages should render the all-clear screen"
build_schedule "$FIXTURE" "$NOW_EPOCH"
OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")"
@@ -185,6 +191,17 @@ if [[ "$OUTPUT" == *$'\033['* ]]; then
printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2
exit 1
fi
SHORT_OUTPUT="$(NO_COLOR=1 COLUMNS=80 LINES=12 render_dashboard "$NOW_EPOCH" 80 "" 12)"
SHORT_LINE_COUNT="$(awk 'END { print NR }' <<<"$SHORT_OUTPUT")"
assert_equal "12" "$SHORT_LINE_COUNT" \
"a dashboard with extra outages should fit the terminal height"
assert_contains "$SHORT_OUTPUT" '01 : 00 : 00' \
"the countdown should remain visible when schedule rows are limited"
assert_contains "$SHORT_OUTPUT" "... 2 more outages" \
"the dashboard should report schedule rows that do not fit"
SHORT_BOTTOM_LINE="${SHORT_OUTPUT##*$'\n'}"
assert_equal "$(repeat_character '=' 80)" "$SHORT_BOTTOM_LINE" \
"the compact schedule should end with a full-width rule"
WIDE_OUTPUT="$(NO_COLOR=1 COLUMNS=180 render_dashboard "$NOW_EPOCH")"
WIDE_TOP_LINE="${WIDE_OUTPUT%%$'\n'*}"
assert_equal "180" "${#WIDE_TOP_LINE}" \
@@ -192,6 +209,8 @@ assert_equal "180" "${#WIDE_TOP_LINE}" \
WIDE_BOTTOM_LINE="${WIDE_OUTPUT##*$'\n'}"
assert_equal "180" "${#WIDE_BOTTOM_LINE}" \
"the dashboard bottom divider should span the full terminal width"
assert_equal "$(repeat_character '=' 180)" "$WIDE_BOTTOM_LINE" \
"the dashboard should end with the same rule as the countdown section"
FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)"
build_schedule "$FIXTURE" "$FUTURE_NOW"