wren-linux / tools / test-timer.sh
#!/bin/bash
# Checks bin/timer (timers, alarms, clock format) with pretend systemd, notify-send
# and sound player, so nothing is started and nothing rings. Run by test.sh, or:
# bash tools/test-timer.sh
# shellcheck disable=SC2016 # the inner bash, and sway's own variables
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
TIMER=$HERE/bin/timer
T=$(mktemp -d) || { echo " FAIL couldn't make a temp folder (is /tmp full?)"; exit 1; }
trap 'rm -rf "$T"' EXIT
fails=0
check() { # DESCRIPTION CONDITION-COMMAND...
local what=$1
shift
if "$@" >/dev/null 2>&1; then printf ' ok %s\n' "$what"; else printf ' FAIL %s\n' "$what"; fails=$((fails + 1)); fi
}
# pretend programs that only write down how they were called
mkdir -p "$T/fake" "$T/run" "$T/config" "$T/home"
for c in systemd-run systemctl notify-send pw-play; do
printf '#!/bin/sh\necho "%s $*" >>"%s/calls"\nexit 0\n' "$c" "$T" >"$T/fake/$c"
done
# (systemctl: your sound server isn't running while $T/no-sound-server exists)
printf '#!/bin/sh\ncase "$*" in *is-active*) [ ! -e "%s/no-sound-server" ] || exit 3 ;; esac\necho "systemctl $*" >>"%s/calls"\nexit 0\n' "$T" "$T" >"$T/fake/systemctl"
printf '#!/bin/sh\nexit 0\n' >"$T/fake/sleep"
chmod +x "$T/fake/"*
export PATH="$T/fake:$PATH" HOME="$T/home" XDG_RUNTIME_DIR="$T/run" XDG_CONFIG_HOME="$T/config"
t() { bash "$TIMER" "$@"; }
calls() { cat "$T/calls" 2>/dev/null; }
files() { find "$T/run/wren-timers" -type f 2>/dev/null | wc -l; }
# 1. how long a person may say it takes
for pair in 90s=90 10m=600 1h30m=5400 1h30=5400 5m30=330 90=5400 1.5h=5400 45S=45; do
check "duration ${pair%=*} is ${pair#*=} seconds" test "$(t seconds "${pair%=*}")" = "${pair#*=}"
done
for bad in 0 abc 5x 5m5x 1000h -3; do
check "'$bad' is not a duration" bash -c "! bash '$TIMER' seconds '$bad'"
done
# 2. a timer
out=$(t 10m tea 2>&1)
check "timer says it is set" grep -q 'Timer set (tea)' <<<"$out"
check "timer made one file" test "$(files)" = 1
check "timer asked systemd for a user timer at a clock time" grep -q -- 'systemd-run --user .*--on-calendar=20' <<<"$(calls)"
check "list shows its name" grep -q 'tea' <<<"$(t list)"
check "the bar text counts down in minutes" test "$(t next)" = "tea 10m"
# 3. a second one, sorted soonest first, and a count in the bar
t 2h bake >/dev/null
check "bar shows the soonest and how many more" test "$(t next)" = "tea 10m +1"
check "list has both" test "$(t list | wc -l)" = 2
# 4. cancel one, then all
t cancel 1 >/dev/null
check "cancel 1 stops that timer's unit" grep -q 'systemctl --user stop wren-timer-' <<<"$(calls)"
check "one is left" test "$(files)" = 1
check "the one left is the other" bash -c "[[ \$(bash '$TIMER' next) == bake\ [12]h* ]]"
t cancel all >/dev/null
check "cancel all leaves nothing" test "$(files)" = 0
check "nothing running says so" grep -q 'Nothing is running' <<<"$(t list)"
check "next is empty when nothing runs" test -z "$(t next)"
check "cancel of a missing number fails" bash -c "t() { bash '$TIMER' \"\$@\"; }; t 5m x >/dev/null; ! bash '$TIMER' cancel 9"
t cancel all >/dev/null
# 5. an alarm: the next such time, never in the past, within a day and a bit
for when in "7am" "7:30 pm" "19:30" "at 6:15 wake up" "tomorrow 8am"; do
# shellcheck disable=SC2086 # "at 6:15 wake up" is meant to be several words
t $when >/dev/null 2>&1
done
now=$(date +%s)
ok=1
for f in "$T/run/wren-timers/"*; do
due=$(sed -n 's/^due=//p' "$f")
[ "$due" -gt "$now" ] && [ "$due" -le $((now + 2 * 86400)) ] || ok=0
done
check "five alarms made five files" test "$(files)" = 5
check "every alarm is in the future, within two days" test "$ok" = 1
check "alarm keeps its name" grep -q 'label=wake up' "$T/run/wren-timers/"*
check "an alarm shows in the bar as alarm text" bash -c "[[ \$(bash '$TIMER' next) == *' +4' ]]"
t cancel all >/dev/null
# 6. things it should refuse
for bad in banana "at" "tomorrow" "25:00" "now" "friday"; do
check "refuses '$bad'" bash -c "! bash '$TIMER' $bad"
done
check "refused things leave no files" test "$(files)" = 0
# 7. ringing: a notification, then the file is gone
t 1m "pasta" >/dev/null
id=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
t fire "$id"
check "ringing sends a notification with the name" grep -q 'notify-send -u critical -t 0 -a wren Timer done pasta' <<<"$(calls)"
check "with the alarm sound missing the notification says so" grep -q 'Timer done pasta (no sound: the alarm sound is missing' <<<"$(calls)"
check "...and nothing is played" bash -c "! grep -q '^pw-play' '$T/calls'"
# pw-play there (the sandboxed browsers install it) but no sound server of yours (no sound
# extra): it plays nothing, so it isn't tried, and the notification says why (the ultra review:
# it said "click to stop the sound" over silence)
mkdir -p "$T/sounds"; : >"$T/sounds/alarm.wav"
: >"$T/no-sound-server"
t 1m "eggs" >/dev/null
id2=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
WREN_SOUND_DIR="$T/sounds" t fire "$id2"
check "no sound server of yours: the notification says so, and nothing is played" bash -c '
grep -q "Timer done eggs (no sound: wrenctl --apps, then check pipewire-audio)" <<<"$1" && ! grep -q "^pw-play" <<<"$1" &&
! grep -q "stop the sound" <<<"$1"' _ "$(calls)"
rm -f "$T/no-sound-server"
rm -f "$T/sounds/alarm.wav"
check "ringing removes the timer" test "$(files)" = 0
check "ringing twice does nothing" t fire "$id"
mkdir -p "$T/sounds" && : >"$T/sounds/alarm.wav"
export WREN_SOUND_DIR="$T/sounds"
t 1m "tea" >/dev/null
id=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
t fire "$id"
check "a timer rings the sound twice (about 20 seconds)" test "$(grep -c "^pw-play $T/sounds/alarm.wav" "$T/calls")" -eq 2
check "...and the notification has no apology" bash -c "! grep -q '(no sound' '$T/calls'"
# choosing the sound (lib/alarm-sounds.py is real here, and makes real files)
cp "$HERE/lib/alarm-sounds.py" "$T/sounds/"
: >"$T/calls"
out=$(t sound chimes)
check "timer sound NAME: chosen, and you hear it" bash -c 'grep -q "^pw-play $1/sounds/alarms/chimes.wav" "$1/calls"' _ "$T"
check "...it's made the first time it's needed, a real sound" python3 -I -c '
import sys, wave
w = wave.open(sys.argv[1])
assert w.getnchannels() == 1 and w.getsampwidth() == 2 and 7 < w.getnframes() / w.getframerate() < 9
' "$T/sounds/alarms/chimes.wav"
t 1m "tea" >/dev/null
id=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
t fire "$id"
check "...a timer then rings with it" test "$(grep -c "^pw-play $T/sounds/alarms/chimes.wav" "$T/calls")" -eq 2
rm -f "$T/sounds/alarms/chimes.wav"
t 1m "tea" >/dev/null
id=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
t fire "$id"
check "...its file gone: the bell rings instead, never silence" test "$(grep -c "^pw-play $T/sounds/alarm.wav" "$T/calls")" -eq 2
out=$(t sound 'nope;rm' 2>&1); rc=$?
check "a name that isn't one: refused, nothing chosen" bash -c '[ "$1" != 0 ] && grep -q "chimes" <<<"$2" && grep -q "^sound=chimes" "$3"' _ "$rc" "$out" "${XDG_CONFIG_HOME:-$HOME/.config}/wren/clock"
: >"$T/calls"
out=$(t sound bell)
check "timer sound bell: back to the first one" bash -c 'grep -q "^pw-play $1/sounds/alarm.wav" "$1/calls"' _ "$T"
out=$(t sound list)
check "timer sound list: all of them, the chosen one marked" bash -c 'for n in bell chimes wren beeps rising gong; do grep -q "$n" <<<"$1" || exit 1; done; grep -q "^\* .*bell" <<<"$1"' _ "$out"
check "the sounds differ from each other, and each is made by arithmetic alone" bash -c '
d=$(mktemp -d); python3 -I "$1/lib/alarm-sounds.py" make "$d" && [ "$(sha256sum "$d"/*.wav | cut -d" " -f1 | sort -u | wc -l)" = 5 ]; r=$?; rm -rf "$d"; exit $r' _ "$HERE"
check "...nothing from the network or outside the standard library" bash -c '! grep -qE "^(import|from) +(requests|urllib|numpy|socket)" "$1"' _ "$HERE/lib/alarm-sounds.py"
check "the base part installs the sound maker" grep -q 'lib/alarm-sounds.py' "$HERE/parts/core-commands.sh"
# The wren is the default: first in the list, and what rings until you choose another
conf=${XDG_CONFIG_HOME:-$HOME/.config}/wren/clock
ring_with() { # the sound one timer rings with
t 1m "tea" >/dev/null
id=$(find "$T/run/wren-timers" -type f -printf '%f')
: >"$T/calls"
t fire "$id"
grep '^pw-play' "$T/calls" | head -n 1 | cut -d' ' -f2
}
sed -i '/^sound=/d' "$conf"
rm -rf "$T/sounds/alarms"
check "no sound chosen, the wren not made yet: the bell, never silence" test "$(ring_with)" = "$T/sounds/alarm.wav"
python3 -I "$T/sounds/alarm-sounds.py" make "$T/sounds/alarms"
check "no sound chosen: the wren rings" test "$(ring_with)" = "$T/sounds/alarms/wren.wav"
out=$(t sound list)
check "...it is first in the list, and marked" bash -c 'grep -q "^\* 1) wren$" <<<"$1"' _ "$out"
check "...the bell is still there, next" bash -c 'grep -q "^ 2) bell$" <<<"$1"' _ "$out"
out=$(t sound bell)
check "a sound you chose stays yours: the bell, once chosen, rings" test "$(ring_with)" = "$T/sounds/alarm.wav"
out=$(t sound list)
check "...and is the one marked" bash -c 'grep -q "^\* 2) bell$" <<<"$1"' _ "$out"
check "the base part makes the sounds at install, so the first alarm has its wren" bash -c 'grep -q "alarm-sounds.py\" make" "$1"' _ "$HERE/parts/core-commands.sh"
# (the real /bin/sleep below: the pretend sleep above does not wait)
# 7b. an alarm rings long, and there are three ways to stop it. These use a player that
# really takes time and a notification program that supports --wait and --action.
mkdir -p "$T/ring"
# shellcheck disable=SC2016 # written into the pretend program, expanded when it runs
printf '#!/bin/sh\necho "pw-play $*" >>"%s/calls"\n/bin/sleep "${FAKE_PLAY:-0}"\n' "$T" >"$T/ring/pw-play"
cat >"$T/ring/notify-send" <<FAKE
#!/bin/bash
echo "notify-send \$*" >>"$T/calls"
case " \$* " in
*" --help "*) echo " -A, --action --wait"; exit 0 ;;
*" --wait "*) /bin/sleep "\${FAKE_NOTIFY_WAIT:-100}" ;;
esac
FAKE
chmod +x "$T/ring/"*
ringing() { local saved=$PATH rc; PATH="$T/ring:$PATH"; "$@"; rc=$?; PATH=$saved; return $rc; }
alarm_id() { t at 7:30 wake >/dev/null; find "$T/run/wren-timers" -type f -name '[0-9]*' -printf '%f\n' | head -1; }
# how long: an alarm 30 plays by default (5 minutes), a timer 2 (about 20 seconds)
check "an alarm rings for 300 seconds, a timer for 20, unless told otherwise" bash -c "grep -q 'RING_ALARM=\${WREN_RING_ALARM:-300}' '$TIMER' && grep -q 'RING_TIMER=\${WREN_RING_TIMER:-20}' '$TIMER'"
id=$(alarm_id)
: >"$T/calls"
WREN_RING_ALARM=300 FAKE_PLAY=1 FAKE_NOTIFY_WAIT=4 ringing t fire "$id" &
fire_pid=$!
/bin/sleep 2.5
check "an alarm repeats the sound (30 times in all) until it is stopped" test "$(grep -c '^pw-play' "$T/calls")" -ge 2
check "the notification offers a Stop action and says how to stop it" grep -q 'notify-send --wait -A default=Stop -u critical -t 0 -a wren Alarm wake. Click or dismiss this to stop the sound.' "$T/calls"
t stop >"$T/out" 2>&1
wait "$fire_pid"
check "timer stop ends the ringing" grep -q 'Stopped the sound' "$T/out"
check "...and the alarm stops repeating" test "$(grep -c '^pw-play' "$T/calls")" -lt 30
check "...and leaves no ring behind" test ! -e "$T/run/wren-timers/ringing"
check "timer stop with nothing ringing and nothing pending says so" grep -q 'Nothing is running' <(t stop 2>&1)
# clicking or dismissing the notification ends it (the notify-send --wait returns)
id=$(alarm_id)
: >"$T/calls"
start=$SECONDS
WREN_RING_ALARM=300 FAKE_PLAY=3 FAKE_NOTIFY_WAIT=0.7 ringing t fire "$id"
took=$((SECONDS - start))
check "dismissing the notification stops the sound at once, not after the whole alarm" test "$took" -lt 6
check "...after one play" test "$(grep -c '^pw-play' "$T/calls")" -eq 1
# without --wait support the notification is plain and the sound plays through
id=$(alarm_id)
: >"$T/calls"
WREN_RING_ALARM=30 env PATH="$T/fake:$PATH" bash "$TIMER" fire "$id"
check "a notify-send without --wait gets a plain notification" bash -c "grep -q '^notify-send -u critical -t 0 -a wren Alarm' '$T/calls' && ! grep -q -e '--wait' '$T/calls'"
check "...and the alarm still rings its full length (3 plays for 30 seconds here)" test "$(grep -c '^pw-play' "$T/calls")" -eq 3
# with a pending timer, stop still cancels it as before
t 5m soon >/dev/null
check "timer stop with nothing ringing cancels a pending timer, as before" bash -c "bash '$TIMER' stop | grep -q -i 'cancel'"
unset WREN_SOUND_DIR
# 8. the clock setting
t clock date off >/dev/null
check "date off is remembered" grep -qx 'date=no' "$T/config/wren/clock"
check "no date looks like a weekday and the time with am or pm" bash -c "[[ \$(bash '$TIMER' clock date off) =~ ^The\ bar\ shows:\ [A-Z][a-z]{2}\ [0-9]{1,2}:[0-9]{2}\ [AP]M\$ ]]"
check "date on looks like the usual bar" bash -c "[[ \$(bash '$TIMER' clock date on) =~ ^The\ bar\ shows:\ [A-Z][a-z]{2}\ [0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{1,2}:[0-9]{2}\ [AP]M\$ ]]"
check "24 hour is not an option" bash -c "! bash '$TIMER' clock 24h"
check "the clock file never has an hours setting" bash -c "! grep -q hours '$T/config/wren/clock'"
check "a wrong clock option fails" bash -c "! bash '$TIMER' clock sideways"
check "the clock file keeps one line per setting" test "$(sort "$T/config/wren/clock" | uniq -d | wc -l)" = 0
# 9. an old file with nothing behind it is dropped
mkdir -p "$T/run/wren-timers"
printf 'kind=timer\ndue=%s\nlabel=old\n' $(($(date +%s) - 1000)) >"$T/run/wren-timers/old"
t list >/dev/null
check "a timer long past is forgotten" test "$(files)" = 0
# 10. Esc closes the menu (it runs in a window of its own, from the bar or Super+t)
cat >"$T/esc.py" <<'PY'
import os, pty, select, sys, time
def run(keys, wait=0.5):
pid, fd = pty.fork()
if pid == 0:
# a size, as a real window has once sway has sized it (a pty starts 0x0)
import fcntl, struct, termios
fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
os.execvpe("/bin/bash", ["bash", sys.argv[1]], dict(os.environ, TERM="xterm"))
out = b""
def pump(w):
nonlocal out
end = time.time() + w
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.05)
if r:
try: d = os.read(fd, 65536)
except OSError: return
if not d: return
out += d
pump(0.8)
for k in keys:
os.write(fd, k.encode()); pump(wait)
for _ in range(40):
done, st = os.waitpid(pid, os.WNOHANG)
if done:
return os.waitstatus_to_exitcode(st), out
time.sleep(0.05)
os.kill(pid, 9); os.waitpid(pid, 0)
return None, out
print("esc", run(["\x1b"])[0])
print("arrow", run(["\x1b[A"])[0])
print("q", run(["q\r"])[0])
status, out = run(["\x1b[A", "\x1b"])
print("arrow-then-esc", status)
print("hint", b"Esc or q" in out)
import re, datetime
print("date", bool(re.search(rb"\d{4}-\d{2}-\d{2}", out)))
PY
esc=$(python3 -I "$T/esc.py" "$TIMER" 2>&1)
check "Esc closes the timer menu" grep -qx 'esc 0' <<<"$esc"
check "an arrow key does not close it (it is history, not Esc)" grep -qx 'arrow None' <<<"$esc"
check "q and Enter still close it" grep -qx 'q 0' <<<"$esc"
check "Esc closes it after an arrow key too" grep -qx 'arrow-then-esc 0' <<<"$esc"
check "the menu says Esc closes it" grep -qx 'hint True' <<<"$esc"
check "the menu shows today's date" grep -qx 'date True' <<<"$esc"
# ...also when the bar has the date turned off
bash "$TIMER" clock date off >/dev/null
esc=$(python3 -I "$T/esc.py" "$TIMER" 2>&1)
check "...even with the date turned off in the bar" grep -qx 'date True' <<<"$esc"
bash "$TIMER" clock date on >/dev/null
# the sound Wren Linux ships: a short, small, valid mono WAV, made by tools/make-alarm-sound.py
check "data/alarm.wav is a valid mono 16-bit sound of a few seconds, under 600 KB" python3 -I -c '
import os, sys, wave
f = sys.argv[1]
w = wave.open(f)
secs = w.getnframes() / w.getframerate()
assert w.getnchannels() == 1 and w.getsampwidth() == 2, "not mono 16-bit"
assert 3 < secs < 20, secs
assert os.path.getsize(f) < 600000
' "$HERE/data/alarm.wav"
check "the base part installs it" grep -q 'data/alarm.wav' "$HERE/parts/core-commands.sh"
check "the sound extra no longer needs a sound theme package" bash -c "! grep -q 'sound-theme' '$HERE/extras/pipewire-audio.sh'"
# How long before the screen locks: timer lock writes it, bin/wren-idle runs
# swayidle with it. swayidle, swaymsg and pkill are pretend ones.
IDLE=$HERE/bin/wren-idle
for c in swayidle swaymsg pkill; do
printf '#!/bin/sh\necho "%s $*" >>"%s/calls"\nexit 0\n' "$c" "$T" >"$T/fake/$c"
chmod +x "$T/fake/$c"
done
export WREN_IDLE=$IDLE
check "with nothing set the screen turns off after 5 minutes (sway's own default)" test "$("$IDLE" --minutes)" = 5
check "timer lock alone says how long" grep -q 'after 5 minutes' <<<"$(t lock)"
: >"$T/calls"
out=$(SWAYSOCK=/pretend t lock 10 2>&1)
check "timer lock 10 says so, and when it locks" grep -q 'now turns off after 10 minutes .* and locks a minute later' <<<"$out"
check "...and wren-idle reads 10" test "$("$IDLE" --minutes)" = 10
check "...and swayidle is started again with it (sway keeps the old timeouts otherwise)" \
bash -c 'grep -q "^pkill -x swayidle" "$1" && grep -q "^swaymsg exec .*wren-idle" "$1"' _ "$T/calls"
t lock 15m >/dev/null
check "timer lock 15m works too" test "$("$IDLE" --minutes)" = 15
for bad in 0 121 abc 5h -3; do
check "timer lock $bad is refused" bash -c '! bash "$1" lock "$2"' _ "$TIMER" "$bad"
done
check "...and the time stays as it was" test "$("$IDLE" --minutes)" = 15
check "without the idle lock set up it says how to get it" \
bash -c 'out=$(WREN_IDLE=/nonexistent bash "$1" lock 10 2>&1); [ $? -ne 0 ] && grep -q "wrenctl part swayidle" <<<"$out"' _ "$TIMER"
: >"$T/calls"
t lock 10 >/dev/null
"$IDLE"
run=$(grep '^swayidle ' "$T/calls")
check "swayidle fades 30 seconds before the screen turns off (570 s)" grep -q 'timeout 570 .*dim-warning start' <<<"$run"
check "...turns the screen off at 600 s" grep -q 'timeout 600 swaymsg "output \* power off"' <<<"$run"
check "...locks a minute later, at 660 s (after the on-lock programs)" grep -qF "; swaylock -f -c \$(~/.local/bin/theme get LOCK 2>/dev/null || echo 000000)" <<<"$(grep -o "timeout 660 .*" <<<"$run")"
check "...and locks before sleep (after them too)" grep -qF '; swaylock -f -c $(~/.local/bin/theme get LOCK 2>/dev/null || echo 000000)' <<<"$(grep -o 'before-sleep .*on-lock.d.*' <<<"$run")"
printf 'minutes=500\n' >"$T/config/wren/lock"
check "a time out of range in the file falls back to 5" test "$("$IDLE" --minutes)" = 5
check "the timer menu offers it" grep -q 'dark after $("$IDLE" --minutes) min idle' "$TIMER"
check "parts/swayidle.sh starts swayidle through wren-idle" grep -qx 'IDLE_CMD="$HOME/.local/bin/wren-idle"' "$HERE/parts/swayidle.sh"
# the menu's screen: one box with this month (ncal's cal) and the timers, centred
mkdir -p "$T/calbin"
cat >"$T/calbin/cal" <<'EOF'
#!/bin/sh
printf '%s\n' ' October 2026 ' 'Su Mo Tu We Th Fr Sa ' ' 1 2 3 ' ' 4 5 6 7 8 9 10 ' \
'11 12 13 14 15 16 17 ' '18 19 20 21 22 23 24 ' '25 26 27 28 29 30 31 ' ' '
EOF
chmod +x "$T/calbin/cal"
bash "$TIMER" cancel all >/dev/null 2>&1
bash "$TIMER" 10m tea >/dev/null
scr() { env PATH="$T/calbin:$PATH" COLUMNS="${C:-100}" LINES="${R:-30}" bash "$TIMER" screen; }
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
not() { ! "$@"; }
widths() { python3 -I -c 'import sys; print(" ".join(sorted({str(len(l)) for l in sys.stdin.read().split("\n") if l})))'; }
# shape TEXT: "ROWS WIDTHS LEFT-MARGIN" of the screen, without colours
shape() { local p; p=$(plain <<<"$1"); printf '%s %s %s' "$(grep -c . <<<"$p")" "$(LANG=C.UTF-8 widths <<<"$p")" "$(grep -m1 . <<<"$p" | grep -o '^ *' | tr -d '\n' | wc -c)"; }
out=$(LANG=C.UTF-8 scr)
check "the screen is one box of 20 rows, every row the same width, centred (14 + 72 + 14)" test "$(shape "$out")" = "20 86 14"
check "...and centred in the window's height too (30 rows: 4 above it)" bash -c '[ "$(sed -n "1,4p" <<<"$1" | grep -c .)" = 0 ] && sed -n 5p <<<"$1" | grep -q "╭"' _ "$out"
check "it shows this month from cal" grep -q 'October 2026' <<<"$(plain <<<"$out")"
today=$(date +%-d)
check "...with today highlighted" grep -qF "$(printf '\033[7m%2s\033[0m' "$today")" <<<"$out"
check "...and the running timer, by name" grep -qE '1\) tea +(10|9) min' <<<"$(plain <<<"$out")"
check "the date and time are at the top, beside the title" grep -qE 'TIMER AND ALARM +[A-Z][a-z]{2} [0-9]{4}-[0-9]{2}-[0-9]{2} ' <<<"$(plain <<<"$out")"
bash "$TIMER" cancel all >/dev/null 2>&1
out=$(LANG=C.UTF-8 scr)
check "with nothing set it says so" grep -q 'Nothing set yet' <<<"$(plain <<<"$out")"
check "...and the box keeps its shape" test "$(shape "$out")" = "20 86 14"
check "the big window offers the choice of sound" grep -q 'a choose its sound' <<<"$(scr)"
check "...and it fits a screen of exactly the box's height (20 + 2), not the small layout" grep -q 'TIMER AND ALARM' <<<"$(R=22 scr)"
out=$(LANG=C.UTF-8 env PATH="$T/fake:/usr/bin:/bin" COLUMNS=100 LINES=30 bash "$TIMER" screen 2>&1)
if ! command -v cal >/dev/null; then
check "without cal it says how to get it" grep -q 'wrenctl --update' <<<"$out"
check "...in the same shape" test "$(shape "$out")" = "20 86 14"
fi
out=$(LANG=C scr)
check "without UTF-8 the box is drawn with plain + - |" bash -c '! grep -q "╭" <<<"$1" && grep -q "^ *+---" <<<"$1"' _ "$out"
check "...in the same shape" test "$(shape "$out")" = "20 86 14"
# A window too small for the box (a small screen, a VM's window): the plain
# layout, every line within the window, so nothing wraps (the box did, in a VM)
fits() { # COLS TEXT: no line wider than COLS, no box drawn
python3 -I -c 'import re, sys; c = int(sys.argv[1]); t = re.sub(r"\x1b\[[0-9;]*m", "", sys.argv[2]); assert all(len(l) <= c for l in t.split("\n")), t; assert "╭" not in t and "+---" not in t' "$1" "$2"
}
bash "$TIMER" 10m tea >/dev/null
out=$(LANG=C.UTF-8 C=60 R=20 scr)
check "a window narrower than the box: the plain layout, every line within it" fits 60 "$out"
check "...still with the date, the timer and how to close" \
bash -c 'grep -qE "TIMER AND ALARM +[A-Z][a-z]{2} [0-9]{4}-" <<<"$1" && grep -q "1) tea: " <<<"$1" && grep -q "Esc or q" <<<"$1"' _ "$(plain <<<"$out")"
check "...and without the month when it is short too" not grep -q 'October 2026' <<<"$(plain <<<"$out")"
out=$(LANG=C.UTF-8 C=70 R=30 scr)
check "a narrow but tall window: the plain layout with the month" \
bash -c 'grep -q "October 2026" <<<"$1"' _ "$(plain <<<"$out")"
check "...every line within it" fits 70 "$out"
out=$(LANG=C.UTF-8 C=100 R=18 scr)
check "a wide but short window: the plain layout too (the box would scroll off)" fits 100 "$out"
bash "$TIMER" cancel all >/dev/null 2>&1
# A new window reports 0x0 until sway sizes it: that never counts as settled
# (it did: four 0x0 in a row, and the box was drawn for 80 columns, at the left)
cat >"$T/settle.sh" <<'EOF'
# (a file counts the calls: settle runs stty inside $(...), a subshell)
echo 0 >"$2"
stty() { local n; n=$(($(cat "$COUNT") + 1)); echo "$n" >"$COUNT"; if [ "$n" -le 10 ]; then echo "0 0"; else echo "40 120"; fi; }
COUNT=$2
eval "$(sed -n '/^settle() {/,/^}/p' "$1")"
settle
cat "$COUNT"
EOF
calls=$(bash "$T/settle.sh" "$TIMER" "$T/settle-count")
check "the wait doesn't take a window's 0x0 (not sized yet) for its size" test "$calls" -gt 10
check "the menu waits for its window to reach its size before drawing (sway stretches a new one)" \
python3 -I -c 'import sys; s = open(sys.argv[1]).read(); m = s.index("menu() {"); assert m < s.index(" settle\n", m) < s.index("draw_screen\n", s.index(" settle\n", m))' "$TIMER"
check "parts/core-commands.sh installs ncal (cal) and --remove takes it out" \
bash -c 'grep -qx "apt_install ncal" "$1" && grep -qx " apt_purge ncal" "$1"' _ "$HERE/parts/core-commands.sh"
check "...and bash-doc (the Bash Reference Manual, for wrenctl learn), and --remove takes it out too" \
bash -c 'grep -qx "apt_install bash-doc" "$1" && grep -qx " apt_purge bash-doc" "$1"' _ "$HERE/parts/core-commands.sh"
[ "$fails" -eq 0 ]