Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)

* Move Elsewhen into Omarchy as omarchy.elsewhen

The world clock ships in the shell tree instead of its own package. A
migration renames existing bar entries with their settings and removes
the retired package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove Elsewhen's shelved Earth row, overlap band and sky tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tidy Elsewhen's models, data helper and docs, and drop currency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restructure Elsewhen's panel and globe on the shared shell components

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Elsewhen's bar globe upright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the shared city search for Elsewhen's globe jump bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep legacy Elsewhen entries single and retry offline geocodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch Elsewhen's weather with curl instead of a Python helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 21:15:00 +02:00
1 parent 5c7cef1e78
commit e7218e36f2
37 files changed
+6780 -155

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ pass "default center anchor exists in center layout"
jq -e '
def ids: map(.id // .);
(.bar.layout.center | ids) as $ids |
($ids | index("omacom.elsewhen")) as $elsewhen |
($ids | index("omarchy.elsewhen")) as $elsewhen |
($ids | index("omarchy.clock")) as $clock |
$elsewhen != null and $clock == $elsewhen + 1
' "$ROOT/config/omarchy/shell.json" >/dev/null
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const { layout } = requireFromRoot('shell/plugins/panels/elsewhen/Arc.js')
const near = (a, b, eps) => Math.abs(a - b) <= (eps === undefined ? 0.01 : eps)
const mono = (count, width) => new Array(count).fill(width === undefined ? 6 : width)
const RISE = 6
// ---- a flat line
const flat = layout(mono(10), 0, true)
assert(flat.height === 0 && flat.width === 60, 'elsewhen lays a flat line out full width with no height')
assertEqual(flat.chars.map(c => c.x).join(), '0,6,12,18,24,30,36,42,48,54', 'elsewhen lays a flat line out end to end')
assert(flat.chars.every(c => c.rotation === 0), 'elsewhen turns nothing on a flat line')
assertEqual(layout([], 6, true).chars.length, 0, 'elsewhen lays out empty text')
assertEqual(layout(mono(4), -3, true).height, 0, 'elsewhen treats a negative rise as flat')
// ---- the rise
for (const count of [20, 30, 50, 90]) {
const r = layout(mono(count), RISE, true)
const ys = r.chars.map(c => c.y)
const dip = Math.max(...ys) - (ys[0] + ys[ys.length - 1]) / 2
assert(near(r.height, RISE, 0.05), `elsewhen bends ${count} characters by the rise`, r.height)
// The end glyphs sit half a character in from the ends of the arc.
assert(dip > RISE * 0.85 && dip <= RISE, `elsewhen dips ${count} glyphs by about the rise`, dip)
}
// ---- shape
const smile = layout(mono(30), RISE, true)
const frown = layout(mono(30), RISE, false)
const last = smile.chars.length - 1
assert(smile.chars[15].y > smile.chars[0].y && frown.chars[15].y < frown.chars[0].y, 'elsewhen dips a smile and raises a frown')
assert(smile.chars.every((c, i) => near(c.y, smile.height - frown.chars[i].y) && near(c.rotation, -frown.chars[i].rotation) && near(c.x, frown.chars[i].x)), 'elsewhen mirrors a smile and a frown')
assert(smile.chars.every((c, i) => near(c.y, smile.chars[last - i].y) && near(c.rotation, -smile.chars[last - i].rotation)), 'elsewhen bends symmetrically')
assert(smile.chars.every((c, i) => i === 0 || c.x > smile.chars[i - 1].x) && near(smile.chars[0].x, 0), 'elsewhen advances from the left edge')
// Qt turns clockwise for a positive angle.
assert(smile.chars[0].rotation > 0 && smile.chars[last].rotation < 0, 'elsewhen leans into each end')
assert(near(smile.chars[14].rotation, -smile.chars[15].rotation), 'elsewhen levels the middle')
const offTangent = []
for (let i = 1; i < smile.chars.length; i++) {
const dy = smile.chars[i].y - smile.chars[i - 1].y
const dx = smile.chars[i].x - smile.chars[i - 1].x
const turn = (smile.chars[i].rotation + smile.chars[i - 1].rotation) / 2
if (!near(Math.atan2(dy, dx) * 180 / Math.PI, turn, 0.1)) offTangent.push(i)
}
assertDeepEqual(offTangent, [], 'elsewhen turns each character along the arc')
// ---- proportional text and reserved width
const prop = layout([4, 4, 4, 16, 16, 16].concat(mono(24)), RISE, true)
assert(near(prop.chars[1].x - prop.chars[0].x, 4, 0.1) && near(prop.chars[4].x - prop.chars[3].x, 16, 0.1), 'elsewhen keeps proportional advances')
const wide = layout(mono(40), 20, true)
assert(wide.chars.every(c => c.x >= -0.001 && c.x + 6 <= wide.width + 0.001), 'elsewhen reserves width for every box')
assert(wide.width < 240 && wide.width > 230, 'elsewhen narrows the arc only a little', wide.width)
const absurd = layout(mono(10), 1000, true)
assert(isFinite(absurd.height) && absurd.height <= 15.01, 'elsewhen clamps an absurd rise', absurd.height)
assert(absurd.chars.length === 10 && absurd.chars.every(c => isFinite(c.x) && isFinite(c.y) && isFinite(c.rotation)), 'elsewhen still lays out every character under an absurd rise')
JS
@@ -1,132 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin" "$test_dir/home"
export CALL_LOG="$test_dir/calls"
cat >"$test_dir/bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
printf 'package %s\n' "$*" >>"$CALL_LOG"
exit "${PACKAGE_STATUS:-0}"
SH
cat >"$test_dir/bin/omarchy-shell" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$CALL_LOG"
quiet=0
if [[ $1 == "-q" ]]; then
quiet=1
shift
fi
if [[ ${SHELL_ABSENT:-0} == 1 ]]; then
(( quiet )) && exit 0
echo "omarchy-shell is not running" >&2
exit 1
fi
[[ $2 != "rescanPlugins" ]] || exit 0
printf '%s\n' "${TEST_PUT_RESULT:-ok}"
SH
cat >"$test_dir/bin/omarchy-restart-shell" <<'SH'
#!/bin/bash
echo 'migration must leave the restart to omarchy update' >&2
exit 1
SH
chmod +x "$test_dir/bin/"*
mkdir -p "$test_dir/packaged/shell/plugins/omacom.elsewhen"
migration="$ROOT/migrations/1790042972.sh"
sed "s|/usr/share/omarchy|$test_dir/packaged|g" "$migration" >"$test_dir/migration.sh"
plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen"
run_migration() {
: >"$CALL_LOG"
HOME="$test_dir/home" OMARCHY_PATH="${1:-$test_dir/packaged}" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1
}
if PACKAGE_STATUS=1 run_migration; then
fail "package failure stops the migration"
fi
[[ ! -e $plugin && ! -L $plugin && $(cat "$CALL_LOG") == "package elsewhen" ]] ||
fail "package failure leaves the plugin and shell untouched"
pass "package failure stops before changing the shell"
run_migration
[[ ! -e $plugin && ! -L $plugin ]] || fail "migration does not create a user plugin link"
[[ ! -e $ROOT/config/omarchy/plugins/omacom.elsewhen && ! -L $ROOT/config/omarchy/plugins/omacom.elsewhen ]] || fail "fresh installs do not ship a user plugin link"
pass "migration and fresh installs rely on the packaged plugin directory"
expected=$'package elsewhen\n-q shell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}'
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan and placement run in order" "$(cat "$CALL_LOG")"
pass "real bar helper enables and places before the clock without restarting during reload"
run_migration
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun"
[[ ! -e $plugin && ! -L $plugin ]] || fail "rerunning the migration does not create a user plugin link"
pass "migration can be rerun without a user plugin link"
run_migration "$ROOT"
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev checkout links the packaged plugin"
pass "dev checkout discovers Elsewhen through a user plugin link"
run_migration "$ROOT"
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev link survives a rerun"
pass "dev link is idempotent"
rm "$plugin"
mkdir -p "$plugin"
printf 'local work\n' >"$plugin/notes"
run_migration "$ROOT"
[[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved"
pass "existing checkout and local files are preserved"
rm "$plugin/notes"
rmdir "$plugin"
ln -s "$test_dir/custom-plugin" "$plugin"
run_migration "$ROOT"
[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "existing symlink is preserved"
pass "existing symlink is preserved, including a missing target"
# An earlier revision, and a symlink once shipped under config/, pointed every
# install at the package's old path.
for tree in "$ROOT" "$test_dir/packaged"; do
ln -sfn "$test_dir/packaged/plugins/omacom.elsewhen" "$plugin"
run_migration "$tree"
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] ||
fail "a stranded link to the package's old path is re-pointed (OMARCHY_PATH=$tree)" "$(readlink "$plugin")"
done
pass "a stranded link to the package's old path is re-pointed on every install"
rm "$plugin"
if env TEST_PUT_RESULT=unknown HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
fail "an unknown widget must leave the migration pending"
fi
pass "an unknown widget leaves the migration pending"
# An update with no shell to ask, from a TTY or with the shell down, still
# finishes: the package and link land, the placement is skipped, and the update
# restarts the shell afterwards.
rm "$plugin"
: >"$CALL_LOG"
if ! env SHELL_ABSENT=1 OMARCHY_SHELL_ABSENT_ATTEMPTS=1 HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
fail "an absent shell must not fail the migration" "$(cat "$test_dir/output")"
fi
grep -q "omacom.elsewhen was not put on the bar" "$test_dir/output" || fail "an absent shell is reported" "$(cat "$test_dir/output")"
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "the package and plugin link land without a shell"
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "the rescan is best-effort and the put is still asked" "$(cat "$CALL_LOG")"
pass "an absent shell leaves the update running with the package and link in place"
# The first run of this migration was under 1789581661.sh, before the re-point
# existed; that marker must not stop the renamed file from running there.
state="$test_dir/state"
mkdir -p "$state"
touch "$state/1789581661.sh"
OMARCHY_MIGRATION_STATE="$state" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-migrate" --pending >"$test_dir/pending" || true
grep -qx "$(basename "$migration")" "$test_dir/pending" || fail "the old marker must not satisfy the renamed migration" "$(cat "$test_dir/pending")"
pass "a machine that applied the migration under its old name runs it again"
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash
# ELSEWHEN_ONLINE=1 also checks day and night against Open-Meteo's is_day.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const https = require('https')
const { Solar: G } = requireFromRoot('test/shell.d/elsewhen/solar.js')
const dataDir = path.join(root, 'shell/plugins/panels/elsewhen')
const cities = JSON.parse(fs.readFileSync(path.join(dataDir, 'cities.json'), 'utf8'))
const land = JSON.parse(fs.readFileSync(path.join(dataDir, 'world.json'), 'utf8')).filter(r => r.length >= 40)
const DEG = Math.PI / 180
// ---- projection
const center = G.project(0, 0, 0, 0, 100)
assert(Math.abs(center.x) < 1e-9 && Math.abs(center.y) < 1e-9 && center.visible, 'elsewhen projects the view center to the origin')
assertEqual(G.project(0, 180, 0, 0, 100).visible, false, 'elsewhen hides the antipode')
assert(G.project(90, 0, 0, 0, 100).y < -99, 'elsewhen puts north up at zero tilt')
assert(G.project(0, 45, 0, 0, 100).x > 0, 'elsewhen puts east to the right')
assert(Math.abs(G.project(0, 45, 45, 0, 100).x) < 1e-9, 'elsewhen spin follows the point')
assert([[12, 34], [-56, 78], [80, -170]].every(([lat, lon]) => {
const q = G.project(lat, lon, 20, 15, 100)
return Math.hypot(q.x, q.y) <= 100.0001
}), 'elsewhen projects inside the disc')
// ---- the sun
const jun = G.subsolarPoint(Date.UTC(2026, 5, 21, 12, 0, 0))
assert(Math.abs(jun.lat - 23.44) < 0.5, 'elsewhen puts the June solstice sun near +23.4', jun.lat)
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 11, 21, 12, 0, 0)).lat + 23.44) < 0.5, 'elsewhen puts the December solstice sun near -23.4')
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 2, 20, 12, 0, 0)).lat) < 1, 'elsewhen puts the equinox sun near the equator')
assert(Math.abs(jun.lon) < 5, 'elsewhen puts the noon UTC sun near Greenwich', jun.lon)
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 5, 21, 18, 0, 0)).lon + 90) < 5, 'elsewhen puts the 18:00 UTC sun near 90W')
assert(Math.abs(G.solarElevation(jun.lat, jun.lon, jun) - 90) < 0.01, 'elsewhen has the sun overhead at the subsolar point')
assert(G.solarElevation(-jun.lat, jun.lon + 180, jun) < -89, 'elsewhen has the antipode in deepest night')
assertEqual(G.isDaylight(jun.lat, jun.lon, jun), true, 'elsewhen has daylight at the subsolar point')
assertEqual(G.isDaylight(-jun.lat, jun.lon + 180, jun), false, 'elsewhen has night at the antipode')
assert([[0, 0], [51, 0], [-33, 151], [78, -68]].every(([lat, lon]) => Math.abs(G.solarElevation(lat, lon, jun)) <= 90.01), 'elsewhen keeps elevation in range')
const sub = G.subsolarPoint(Date.now())
const term = G.terminator(sub, 60)
assertEqual(term.length, 61, 'elsewhen closes the terminator')
assert(term.every(([lat, lon]) => {
const cosz = Math.sin(lat * DEG) * Math.sin(sub.lat * DEG) + Math.cos(lat * DEG) * Math.cos(sub.lat * DEG) * Math.cos((lon - sub.lon) * DEG)
return Math.abs(cosz) < 1e-9
}), 'elsewhen draws the terminator 90 degrees from the sun')
// ---- labels and scale
const placed = G.layoutLabels([
{ index: 0, name: 'AAAA', x: 0, y: 0, rank: 1, cosc: 1 },
{ index: 1, name: 'BBBB', x: 2, y: 2, rank: 1, cosc: 0.9 },
{ index: 2, name: 'CCCC', x: 0, y: 80, rank: 1, cosc: 0.8 }
], 7, 12)
assertDeepEqual(placed.map(p => p.index), [0, 2], 'elsewhen drops an overlapping label')
const wide = [{ index: 0, name: 'Bangkok', x: 120, y: 0, rank: 1, cosc: 1 }]
const free = G.layoutLabels(wide, 7, 12, 10)[0].box
const bounded = G.layoutLabels(wide, 7, 12, 10, 150)[0].box
assert(free.x + free.w > 150, 'elsewhen lets an unbounded label overflow')
assert(bounded.x + bounded.w <= 150 && bounded.x < 120 && bounded.x + bounded.w > 60, 'elsewhen flips a bounded label to the left of its dot')
const lagos = [{ index: 0, name: 'Lagos', x: -40, y: 0, rank: 1, cosc: 1 }]
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150)[0].box.x, -34, 'elsewhen leaves a label that fits with the default gap')
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150, 10)[0].box.x, -30, 'elsewhen uses a scaled gap')
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150, 0)[0].box.x, -34, 'elsewhen falls back to the default gap')
assertDeepEqual([G.scalePx(1.4, 1), G.scalePx(1.4, 2), G.scalePx(7.5, 1.5)], [1.4, 2.8, 11.25], 'elsewhen scales drawn sizes')
const ratio = s => G.scalePx(7.5, s) / G.scalePx(2.2, s)
assert(Math.abs(ratio(1) - ratio(1.67)) < 1e-9, 'elsewhen keeps drawn proportions at any scale')
assertDeepEqual([G.scalePx(1, 0.5), G.scalePx(1, 0.5, 0.25)], [1, 0.5], 'elsewhen floors hairlines at an overridable pixel')
assertDeepEqual([G.scalePx(2.5, 0), G.scalePx(2.5, undefined), G.scalePx(2.5, NaN)], [2.5, 2.5, 2.5], 'elsewhen treats a nonsense scale as 1')
assertDeepEqual([[0, 90], [0, -90], [170, -170], [-170, 170], [0, 180], [0, -180], [10, 190], [0, 540]].map(([a, b]) => G.shortestTurn(a, b)), [90, -90, 20, -20, 180, 180, 180, 180], 'elsewhen turns the short way round')
assert(Math.abs(G.shortestTurn(1080 + 5, 10) - 5) < 1e-9, 'elsewhen turns the short way from a wound-up spin')
// ---- clipping to the disc
const R = 28
const DISC = Math.PI * R * R
const area = poly => {
let a = 0
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length
a += poly[i].x * poly[j].y - poly[j].x * poly[i].y
}
return Math.abs(a) / 2
}
let worst = 0
let previous = null
for (let spin = 0; spin < 360; spin += 0.25) {
const total = land.reduce((sum, ring) => sum + area(G.clipRingToDisc(ring, spin, 0, R)), 0)
if (previous !== null) worst = Math.max(worst, Math.abs(total - previous))
previous = total
}
assert(worst < DISC * 0.01, 'elsewhen clipped land area changes smoothly as the globe turns', `${worst.toFixed(2)} px2 per 0.25 degrees`)
assert(land.every(ring => G.clipRingToDisc(ring, 137, 0, R).every(p => Math.hypot(p.x, p.y) <= R + 0.01)), 'elsewhen keeps clipped polygons inside the disc')
assertEqual(G.clipRingToDisc([0, 0, 1, 0, 1, 1], 180, 0, R).length, 0, 'elsewhen clips a far-side ring to nothing')
assert(G.visibleSegments([[0, -170], [0, -90], [0, 0], [0, 90], [0, 170]], 0, 0, R).every(run => run.every(p => Math.hypot(p.x, p.y) <= R + 0.01)), 'elsewhen breaks polylines at the horizon')
const ring = []
for (let a = 0; a < 40; a++) ring.push(Math.cos(a / 40 * 2 * Math.PI) * 30, Math.sin(a / 40 * 2 * Math.PI) * 20)
const half = G.decimateRing(ring, 2, 8)
assert(half.length / 2 <= ring.length / 4 + 1 && half.length % 2 === 0, 'elsewhen halves a ring into lon,lat pairs')
assert(half[0] === ring[0] && half[1] === ring[1], 'elsewhen starts a decimated ring on the same vertex')
assert(half[half.length - 2] === ring[ring.length - 2] && half[half.length - 1] === ring[ring.length - 1], 'elsewhen ends a decimated ring on its own last vertex')
const originals = new Set()
for (let i = 0; i < ring.length; i += 2) originals.add(ring[i] + ',' + ring[i + 1])
let kept = true
for (let i = 0; i < half.length; i += 2) if (!originals.has(half[i] + ',' + half[i + 1])) kept = false
assert(kept, 'elsewhen keeps only original vertices')
const tiny = [0, 0, 1, 0, 1, 1, 0, 1]
assert(G.decimateRing(tiny, 2, 8) === tiny && G.decimateRing(ring, 1, 8) === ring, 'elsewhen leaves small rings and step 1 alone')
const bbox = poly => poly.reduce((b, p) => [Math.min(b[0], p.x), Math.min(b[1], p.y), Math.max(b[2], p.x), Math.max(b[3], p.y)], [Infinity, Infinity, -Infinity, -Infinity])
const coarse = G.clipRingToDisc(half, 10, 0, R)
const full = bbox(G.clipRingToDisc(ring, 10, 0, R))
assert(coarse.length >= 3 && bbox(coarse).every((v, i) => Math.abs(v - full[i]) < R * 0.03), 'elsewhen clips a decimated ring to the same ground')
assert(land.every(r => {
const d = G.decimateRing(r, 2, 8)
return d.length % 2 === 0 && d.length >= 6 && d.length <= r.length
}), 'elsewhen decimates every world.json ring')
// ---- the moon
const eclipses = [
['2017-08-21T18:26Z', 0.0],
['2024-04-08T18:17Z', 0.0],
['2019-01-21T05:12Z', 0.5],
['2022-11-08T10:59Z', 0.5],
['2021-05-26T11:19Z', 0.5]
]
const offByDays = eclipses.map(([iso, want]) => {
let d = Math.abs(G.moonPhase(Date.parse(iso)) - want)
if (d > 0.5) d = 1 - d
return d * G.SYNODIC_MONTH
})
assert(offByDays.every(d => d <= 1), 'elsewhen puts eclipses within a day of new and full moon', offByDays.join(', '))
assert([0, 1e12, Date.now(), Date.UTC(1970, 0, 1)].every(ms => { const p = G.moonPhase(ms); return p >= 0 && p < 1 }), 'elsewhen keeps the moon phase in [0, 1)')
const now = Date.now()
const drift = Math.abs(G.moonPhase(now) - G.moonPhase(now + G.SYNODIC_MONTH * 86400000))
assert(drift < 1e-6 || drift > 1 - 1e-6, 'elsewhen repeats the phase after a synodic month')
const MOON_R = 40
const illumination = phase => (1 - Math.cos(2 * Math.PI * phase)) / 2
const inside = (poly, x, y) => {
let c = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j]
if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c
}
return c
}
const measure = phase => {
const poly = G.moonLitOutline(phase, MOON_R, 96)
let lit = 0, disc = 0, left = 0, right = 0
for (let y = -MOON_R; y <= MOON_R; y += 0.5)
for (let x = -MOON_R; x <= MOON_R; x += 0.5) {
if (x * x + y * y > MOON_R * MOON_R) continue
disc++
if (!inside(poly, x, y)) continue
lit++
if (x < 0) left++; else right++
}
return { frac: lit / disc, left, right }
}
const phases = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]
const measured = phases.map(measure)
assert(phases.every((p, i) => Math.abs(measured[i].frac - illumination(p)) < 0.03), 'elsewhen draws the lit area the phase calls for')
assert(measured[1].right > measured[1].left * 20 && measured[2].left < measured[2].right * 0.05, 'elsewhen lights the right limb while waxing')
assert(measured[7].left > measured[7].right * 20 && measured[6].right < measured[6].left * 0.05, 'elsewhen lights the left limb while waning')
assert(measured[0].frac < 0.01 && measured[4].frac > 0.99, 'elsewhen draws new moon dark and full moon lit')
assert([0, 0.2, 0.4, 0.6, 0.8].every(p => G.moonLitOutline(p, MOON_R, 48).every(q => Math.hypot(q.x, q.y) <= MOON_R + 1e-6)), 'elsewhen keeps the moon outline inside its disc')
// ---- day and night against Open-Meteo
if (process.env.ELSEWHEN_ONLINE) {
const lats = cities.map(c => c[2]).join(',')
const lons = cities.map(c => c[3]).join(',')
https.get(`https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=is_day`, res => {
let body = ''
res.on('data', d => body += d)
res.on('end', () => {
const feed = JSON.parse(body)
const sun = G.subsolarPoint(Date.now())
// Open-Meteo's flag lags up to 15 minutes, so only disagreements away from the horizon count.
const wrong = []
feed.forEach((entry, i) => {
const theirs = (entry.current || {}).is_day
const mine = G.isDaylight(cities[i][2], cities[i][3], sun) ? 1 : 0
if (theirs !== undefined && mine !== theirs && Math.abs(G.solarElevation(cities[i][2], cities[i][3], sun)) > 4) wrong.push(cities[i][0])
})
assertDeepEqual(wrong, [], 'elsewhen agrees with Open-Meteo about day and night')
})
}).on('error', error => fail('elsewhen reaches Open-Meteo', error.message))
}
JS
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const { execFileSync } = require('child_process')
const g = requireFromRoot('shell/plugins/panels/elsewhen/Greetings.js')
const at = (zone, hour) => g.greeting(zone, hour).text
const language = (zone, hour) => g.greeting(zone, hour === undefined ? 9 : hour).language
// ---- coverage of every zone the picker offers
const offered = execFileSync('timedatectl', ['list-timezones'], { encoding: 'utf8' }).trim().split('\n')
const placeless = zone => /^(Etc\/|GMT|UCT$|UTC$|Universal$|Zulu$|Greenwich$|Factory$)/.test(zone)
const places = offered.filter(zone => !placeless(zone))
const zoneIds = new Set(g.zoneIds())
const countries = new Set(g.countryCodes())
assert(places.length > 300, 'elsewhen reads the system zone list', places.length)
assertDeepEqual(places.filter(z => !zoneIds.has(z)), [], 'elsewhen has every offered zone in its table')
assertDeepEqual(places.filter(z => g.countryFor(z) === ''), [], 'elsewhen puts every place in a country')
assertDeepEqual(places.filter(z => !countries.has(g.countryFor(z))), [], 'elsewhen greets every place by its country, never by accident')
assert(at('Etc/GMT+5', 9) === 'Good morning' && g.countryFor('Etc/GMT+5') === '', 'elsewhen still greets offset-only zones')
const cities = JSON.parse(fs.readFileSync(path.join(root, 'shell/plugins/panels/elsewhen/cities.json'), 'utf8'))
assertDeepEqual([...new Set(cities.map(c => c[1]))].filter(z => !zoneIds.has(z)), [], 'elsewhen maps every catalogue city')
// ---- languages, overrides and aliases
assertDeepEqual(
[language('Asia/Jerusalem', 7), g.greeting('Asia/Jerusalem', 7).roman, language('Asia/Singapore', 14), at('Asia/Singapore', 14)],
['Hebrew', 'boker tov', 'Malay', 'Selamat tengah hari'],
'elsewhen greets Tel Aviv and Singapore in their own languages'
)
assertDeepEqual(
['Pacific/Honolulu', 'America/Denver', 'America/Montreal', 'America/Toronto'].map(z => language(z)),
['Hawaiian', 'English', 'French', 'English'],
'elsewhen applies city overrides only to their cities'
)
assertDeepEqual(
['Asia/Calcutta', 'US/Pacific', 'Europe/Kiev'].map(z => language(z)),
['Hindi', 'English', 'Ukrainian'],
'elsewhen resolves aliases like their targets'
)
assert(g.countryCodes().length > 240, 'elsewhen speaks for every country code')
assertDeepEqual(
[at('Mars/Olympus_Mons', 9), language('', 9), at(undefined, 9).length > 0],
['Good morning', 'English', true],
'elsewhen falls back to English for unknown zones'
)
const samePlace = [
['Iceland', 'Atlantic/Reykjavik'],
['NZ', 'Pacific/Auckland'],
['Singapore', 'Asia/Singapore'],
['Asia/Rangoon', 'Asia/Yangon'],
['Africa/Asmera', 'Africa/Asmara'],
['Africa/Timbuktu', 'Africa/Bamako'],
['America/Virgin', 'America/St_Thomas'],
['Pacific/Truk', 'Pacific/Chuuk'],
['Pacific/Yap', 'Pacific/Chuuk'],
['US/Arizona', 'America/Phoenix'],
['MST', 'America/Phoenix'],
['Canada/Eastern', 'America/Toronto'],
['America/Nipigon', 'America/Toronto'],
['America/Thunder_Bay', 'America/Toronto']
]
assertDeepEqual(
samePlace.filter(([alias, canonical]) => g.countryFor(alias) !== g.countryFor(canonical) || at(alias, 9) !== at(canonical, 9)).map(([alias]) => alias),
[],
'elsewhen puts legacy aliases in the country of the place they name'
)
assertDeepEqual([g.countryFor('Antarctica/South_Pole'), g.countryFor('Pacific/Ponape')], ['AQ', 'FM'], 'elsewhen keeps the South Pole and Pohnpei where they are')
assertEqual(language('Europe/Simferopol'), 'Ukrainian', 'elsewhen greets Simferopol in Ukrainian')
assert(g.countryFor('America/Montreal') === g.countryFor('America/Toronto') && language('America/Montreal') === 'French', 'elsewhen greets Montreal in French inside Canada')
// ---- every table
const LATIN = /^[ -~ -ɏɐ-˿̀-ͯḀ-ỿ‘’]+$/
const problems = []
for (const key of g.languageKeys()) {
const bands = g.bandsOf(key)
if (bands[0].from !== 0) problems.push(`${key}: does not start at midnight`)
if (!bands.every((b, i) => i === 0 || b.from > bands[i - 1].from)) problems.push(`${key}: bands do not ascend`)
if (!bands.every(b => b.from >= 0 && b.from <= 23)) problems.push(`${key}: band outside the day`)
bands.forEach((b, i) => {
if (b.text.length === 0 || b.text.trim() !== b.text) problems.push(`${key}: blank or padded greeting`)
const wrap = i === bands.length - 1 && b.text === bands[0].text
if (i > 0 && !wrap && b.text === bands[i - 1].text) problems.push(`${key}: band ${i} repeats the one before`)
if (LATIN.test(b.text) ? b.roman !== '' : b.roman === '') problems.push(`${key}: roman where the script is ${LATIN.test(b.text) ? '' : 'not '}Latin`)
if (b.roman !== '' && !/^[a-z' -]+$/.test(b.roman)) problems.push(`${key}: unsayable roman ${b.roman}`)
})
const zone = g.zoneIds().find(z => g.languageFor(z) === key)
const texts = new Set(bands.map(b => b.text))
if (zone) for (let h = 0; h < 24; h++) if (!texts.has(at(zone, h))) problems.push(`${key}: hour ${h} has no greeting`)
}
assertDeepEqual(problems, [], 'elsewhen language tables cover the day with sayable greetings')
assertEqual(at('Asia/Tokyo', 24), at('Asia/Tokyo', 23), 'elsewhen clamps hour 24 into the day')
assertEqual(at('Asia/Tokyo', -3), at('Asia/Tokyo', 0), 'elsewhen clamps a negative hour to midnight')
assertEqual(at('Asia/Tokyo', 11.9), at('Asia/Tokyo', 11), 'elsewhen truncates a fractional hour')
// ---- the boundaries the tables exist for
assert(at('Asia/Tokyo', 7) !== at('Asia/Tokyo', 13) && at('Asia/Tokyo', 13) !== at('Asia/Tokyo', 20), 'elsewhen walks Tokyo through its day')
assertDeepEqual([at('Europe/Madrid', 20), at('America/Lima', 20)], ['Buenas tardes', 'Buenas noches'], 'elsewhen keeps Madrid in the afternoon after Lima')
assert(language('Europe/Madrid', 20).startsWith('Spanish') && language('America/Lima', 20) === 'Spanish', 'elsewhen greets Madrid and Lima in Spanish')
assert(at('Europe/Vienna', 13) !== at('Europe/Berlin', 13) && at('Europe/Zurich', 13) !== at('Europe/Berlin', 13), 'elsewhen tells Vienna and Zurich from Berlin at midday')
assert(at('Asia/Jakarta', 12) !== at('Asia/Jakarta', 16), 'elsewhen gives Jakarta an afternoon and a late afternoon')
assertEqual(new Set([0, 6, 12, 18, 23].map(h => at('Asia/Yangon', h))).size, 1, 'elsewhen greets Yangon the same all day')
assert(at('Asia/Hong_Kong', 8) !== at('Asia/Shanghai', 8), 'elsewhen does not greet Hong Kong in Mandarin')
JS
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin" "$test_dir/home"
export CALL_LOG="$test_dir/calls"
cat >"$test_dir/bin/omarchy-pkg-drop" <<'SH'
#!/bin/bash
printf 'drop %s\n' "$*" >>"$CALL_LOG"
exit "${PACKAGE_STATUS:-0}"
SH
cat >"$test_dir/bin/omarchy-shell" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$CALL_LOG"
quiet=0
if [[ $1 == "-q" ]]; then
quiet=1
shift
fi
if [[ ${SHELL_ABSENT:-0} == 1 ]]; then
(( quiet )) && exit 0
echo "omarchy-shell is not running" >&2
exit 1
fi
[[ $2 != "rescanPlugins" ]] || exit 0
printf '%s\n' "${TEST_PUT_RESULT:-ok}"
SH
cat >"$test_dir/bin/omarchy-restart-shell" <<'SH'
#!/bin/bash
echo 'migration must leave the restart to omarchy update' >&2
exit 1
SH
chmod +x "$test_dir/bin/"*
run() {
: >"$CALL_LOG"
env HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" "$@" \
bash -euo pipefail "$migration" >"$test_dir/output" 2>&1
}
[[ -f $ROOT/shell/plugins/panels/elsewhen/manifest.json ]] || fail "Elsewhen ships in the shell tree"
[[ $(jq -r .id "$ROOT/shell/plugins/panels/elsewhen/manifest.json") == "omarchy.elsewhen" ]] ||
fail "Elsewhen uses the first-party namespace"
! grep -qx elsewhen "$ROOT/install/omarchy-base.packages" || fail "fresh installs do not install the retired package"
pass "Elsewhen ships in the shell tree as omarchy.elsewhen"
# Placement
migration="$ROOT/migrations/1790042972.sh"
expected=$'-q shell rescanPlugins\nshell putBarWidget omarchy.elsewhen {"before":"omarchy.clock"}'
run
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "scan and placement run in order" "$(cat "$CALL_LOG")"
pass "real bar helper places Elsewhen before the clock without restarting the shell"
run
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "placement can be rerun"
pass "placement can be rerun"
if run TEST_PUT_RESULT=unknown; then
fail "an unknown widget must leave the migration pending"
fi
pass "an unknown widget leaves the migration pending"
# An update with no shell to ask, from a TTY or with the shell down, still
# finishes; the update restarts the shell afterwards.
run SHELL_ABSENT=1 OMARCHY_SHELL_ABSENT_ATTEMPTS=1 || fail "an absent shell must not fail the migration" "$(cat "$test_dir/output")"
grep -q "omarchy.elsewhen was not put on the bar" "$test_dir/output" || fail "an absent shell is reported" "$(cat "$test_dir/output")"
pass "an absent shell leaves the update running"
# An entry under the legacy id is left for the rename, not joined by a second widget.
mkdir -p "$test_dir/home/.config/omarchy"
printf '{"bar":{"layout":{"center":[{"id":"omacom.elsewhen","zones":"Tokyo|Asia/Tokyo"},"omarchy.clock"]}}}\n' \
>"$test_dir/home/.config/omarchy/shell.json"
run
[[ $(cat "$CALL_LOG") == "-q shell rescanPlugins" ]] || fail "a legacy entry skips placement" "$(cat "$CALL_LOG")"
migration="$ROOT/migrations/1790528634.sh"
run
[[ $(jq -c '[.bar.layout.center[] | if type == "object" then .id else . end]' "$test_dir/home/.config/omarchy/shell.json") == '["omarchy.elsewhen","omarchy.clock"]' ]] ||
fail "the legacy entry becomes the only Elsewhen" "$(cat "$test_dir/home/.config/omarchy/shell.json")"
rm "$test_dir/home/.config/omarchy/shell.json"
migration="$ROOT/migrations/1790042972.sh"
pass "a legacy entry is renamed rather than duplicated"
# The first run of this migration was under 1789581661.sh, before a later
# repair existed; that marker must not stop the renamed file from running there.
state="$test_dir/state"
mkdir -p "$state"
touch "$state/1789581661.sh"
OMARCHY_MIGRATION_STATE="$state" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-migrate" --pending >"$test_dir/pending" || true
grep -qx "$(basename "$migration")" "$test_dir/pending" || fail "the old marker must not satisfy the renamed migration" "$(cat "$test_dir/pending")"
pass "a machine that applied the placement under its old name runs it again"
# Package retirement
migration="$ROOT/migrations/1790528634.sh"
plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen"
mkdir -p "${plugin%/*}"
mkdir -p "$test_dir/home/.cache/omacom-elsewhen"
touch "$test_dir/home/.cache/omacom-elsewhen/data.json"
run
[[ $(cat "$CALL_LOG") == "drop elsewhen" ]] || fail "the package is dropped" "$(cat "$CALL_LOG")"
[[ ! -e $test_dir/home/.config/omarchy/shell.json ]] || fail "a missing config is not created"
[[ ! -e $test_dir/home/.cache/omacom-elsewhen ]] || fail "the old cache is removed"
pass "the retired package and its old cache are removed"
config="$test_dir/home/.config/omarchy/shell.json"
cat >"$config" <<'JSON'
{
"bar": {
"centerAnchor": "omacom.elsewhen",
"layout": {
"left": ["omarchy.menu"],
"center": [{ "id": "omacom.elsewhen", "zones": "Tokyo|Asia/Tokyo" }, { "id": "omarchy.clock" }],
"right": ["omacom.elsewhen"]
}
},
"plugins": [{ "id": "someone.else" }],
"disabledPlugins": ["omacom.elsewhen", "omarchy.battery"]
}
JSON
run
expected_config='{"bar":{"centerAnchor":"omarchy.elsewhen","layout":{"left":["omarchy.menu"],"center":[{"id":"omarchy.elsewhen","zones":"Tokyo|Asia/Tokyo"},{"id":"omarchy.clock"}],"right":["omarchy.elsewhen"]}},"plugins":[{"id":"someone.else"}],"disabledPlugins":["omarchy.elsewhen","omarchy.battery"]}'
[[ $(jq -c . "$config") == "$expected_config" ]] || fail "bar entries are renamed with their settings" "$(jq -c . "$config")"
run
[[ $(jq -c . "$config") == "$expected_config" ]] || fail "the rename can be rerun"
pass "bar entries, the center anchor and plugin lists move to omarchy.elsewhen with their settings"
printf 'not json' >"$config"
if run; then
fail "an unreadable config must leave the migration pending"
fi
[[ $(cat "$config") == "not json" ]] || fail "an unreadable config is left untouched"
pass "an unreadable config leaves the migration pending and the file untouched"
rm "$config"
for target in /usr/share/omarchy/shell/plugins/omacom.elsewhen /usr/share/omarchy/plugins/omacom.elsewhen; do
ln -sfn "$target" "$plugin"
run
[[ ! -e $plugin && ! -L $plugin ]] || fail "a link to the packaged plugin is removed ($target)"
done
pass "dev links to the packaged plugin are removed, including the stranded old path"
ln -s "$test_dir/custom-plugin" "$plugin"
run
[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "a link the user made is preserved"
rm "$plugin"
mkdir "$plugin"
printf 'local work\n' >"$plugin/notes"
run
[[ $(cat "$plugin/notes") == "local work" ]] || fail "a local checkout is preserved"
pass "user links and checkouts are preserved"
if run PACKAGE_STATUS=1; then
fail "a failed removal must leave the migration pending"
fi
pass "a failed removal leaves the migration pending"
+336
View File
@@ -0,0 +1,336 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const { execFileSync } = require('child_process')
const M = requireFromRoot('shell/plugins/panels/elsewhen/Model.js')
const probe = (ids, withLocal) => {
const command = M.probeCommand(ids, withLocal)
return execFileSync(command[0], command.slice(1), { encoding: 'utf8' })
}
const near = (a, b, eps) => Math.abs(a - b) <= (eps === undefined ? 1e-9 : eps)
// ---- the date probe
const zoneCommand = M.probeCommand(['Asia/Tokyo', 'Europe/Paris'])
assertDeepEqual(zoneCommand.slice(0, 2), ['bash', '-c'], 'elsewhen probe runs one bash')
assertDeepEqual(zoneCommand.slice(3), ['bash', 'Asia/Tokyo', 'Europe/Paris'], 'elsewhen probe passes zones as arguments, not script text')
assert(zoneCommand[2].indexOf('Asia/Tokyo') === -1, 'elsewhen probe script never contains a zone id')
const probed = M.parseProbe(probe(['Asia/Tokyo', 'Asia/Kathmandu', 'UTC']))
assertDeepEqual(probed['Asia/Tokyo'], { abbr: 'JST', offsetMinutes: 540 }, 'elsewhen probe reads Tokyo')
assertEqual(probed['Asia/Kathmandu'].offsetMinutes, 345, 'elsewhen probe reads a three-quarter-hour zone')
assertEqual(probed.UTC.offsetMinutes, 0, 'elsewhen probe reads UTC')
const local = probe(['Asia/Tokyo'], true)
const localZone = M.localZoneFromProbe(local)
assert(localZone !== '', 'elsewhen probe names the local zone')
assert(M.parseProbe(local)[localZone] !== undefined, 'elsewhen probe also gives the local zone an offset line')
assert(M.parseProbe(local)['Asia/Tokyo'] !== undefined, 'elsewhen local probe still probes the listed zones')
assertEqual(M.parseProbe(local).LOCAL, undefined, 'elsewhen LOCAL marker is not a zone')
assertEqual(M.parseOffset('-0700'), -420, 'elsewhen parses a negative offset')
assertEqual(M.parseOffset('+0545'), 345, 'elsewhen parses a positive offset')
assertEqual(M.parseOffset('0700'), null, 'elsewhen rejects an unsigned offset')
assertDeepEqual(M.parseProbe('Bad|X\nEurope/Paris|CET|nope\n\n'), {}, 'elsewhen ignores unparseable probe lines')
// ---- clocks
const at = Date.UTC(2026, 5, 21, 13, 5)
assertEqual(M.formatClock(at, 0, true), '13:05', 'elsewhen formats a 24-hour clock')
assertEqual(M.formatClock(at, 0, false), '1:05 PM', 'elsewhen formats a 12-hour clock')
assertEqual(M.formatClock(at, 540, false), '10:05 PM', 'elsewhen applies the zone offset')
assertEqual(M.formatClock(at, -13 * 60 - 5, true), '00:00', 'elsewhen pads midnight on a 24-hour clock')
assertEqual(M.formatClock(at, -13 * 60 - 5, false), '12:00 AM', 'elsewhen shows midnight as 12 AM')
assertEqual(M.formatClock(at, -60 - 5, false), '12:00 PM', 'elsewhen shows noon as 12 PM')
assertEqual(M.formatClock(at, -5 * 60, true), '08:05', 'elsewhen pads a morning hour')
assertEqual(M.formatClock(at, undefined, true), '', 'elsewhen shows nothing before the offset is known')
// ---- scrubbing
assertEqual(Math.round(M.scrubDeltaMinutes(0.5, 600)), 120, 'elsewhen scrubs forward')
assertEqual(Math.round(M.scrubDeltaMinutes(0.5, 840)), -120, 'elsewhen scrubs backward')
assertEqual(Math.round(M.scrubDeltaMinutes(0.0, 1380)), 60, 'elsewhen scrubs the short way round midnight')
assertEqual(Math.round(M.scrubDeltaMinutes(2, 720)), 720, 'elsewhen clamps a scrub past the right edge')
assertEqual(Math.round(M.scrubDeltaMinutes(-1, 720)), 720, 'elsewhen resolves a half-day scrub forward')
assertEqual(Math.round(M.scrubDeltaMinutes(1, 720)), 720, 'elsewhen resolves a half-day scrub forward from the other edge')
assertEqual(Math.round(M.scrubDeltaMinutes(600 / 1440, 600)), 0, 'elsewhen scrub at the current time is zero')
assertDeepEqual([M.formatScrubDelta(0), M.formatScrubDelta(45), M.formatScrubDelta(-90), M.formatScrubDelta(180)], ['', '+45m', '-1h 30m', '+3h'], 'elsewhen formats scrub deltas')
assertDeepEqual([M.formatMinuteOfDay(0, false), M.formatMinuteOfDay(13 * 60 + 5, false), M.formatMinuteOfDay(13 * 60, true)], ['12:00 AM', '1:05 PM', '13:00'], 'elsewhen formats minutes of the day')
assertEqual(M.formatMinuteOfDay(419.81, false), '7:00 AM', 'elsewhen rolls the hour when rounding seconds')
assertEqual(M.formatMinuteOfDay(419.81, true), '07:00', 'elsewhen rolls the hour on a 24-hour clock')
assertEqual(M.formatMinuteOfDay(419.5, false), '7:00 AM', 'elsewhen rounds half a minute up')
assertEqual(M.formatMinuteOfDay(419.49, false), '6:59 AM', 'elsewhen rounds under half a minute down')
assertEqual(M.formatMinuteOfDay(719.7, false), '12:00 PM', 'elsewhen rounds into noon')
assertEqual(M.formatMinuteOfDay(1439.7, false), '12:00 AM', 'elsewhen wraps the last minute of the day')
assertEqual(M.formatMinuteOfDay(1439.7, true), '00:00', 'elsewhen wraps the last minute on a 24-hour clock')
assertEqual(M.formatMinuteOfDay(375, false), '6:15 AM', 'elsewhen leaves whole minutes alone')
// ---- offset labels
assertDeepEqual(
[0, 120, -300, 345, -210, 570, 840, -720, 65].map(M.utcOffsetLabel),
['UTC', 'UTC+2', 'UTC-5', 'UTC+5:45', 'UTC-3:30', 'UTC+9:30', 'UTC+14', 'UTC-12', 'UTC+1:05'],
'elsewhen labels absolute offsets'
)
assertDeepEqual([undefined, null, NaN].map(M.utcOffsetLabel), ['', '', ''], 'elsewhen labels unknown offsets as nothing')
assertEqual(M.relativeOffsetLabel(540, 0), '+9h', 'elsewhen labels a zone ahead')
assertEqual(M.relativeOffsetLabel(-300, -120), '-3h', 'elsewhen labels a zone behind')
assertEqual(M.relativeOffsetLabel(345, 0), '+5.8h', 'elsewhen rounds quarter zones to a tenth')
assertEqual(M.relativeOffsetLabel(330, 0), '+5.5h', 'elsewhen keeps half-hour zones')
assertEqual(M.relativeOffsetLabel(570, 600), '-0.5h', 'elsewhen labels Adelaide from Sydney')
assertEqual(M.relativeOffsetLabel(-480, -480), '', 'elsewhen says nothing on your own offset')
// ---- editing and selection
const zones = M.parseZones('Paris|Europe/Paris, Tokyo|Asia/Tokyo, New York|America/New_York')
const labels = list => list.map(z => z.label).join(',')
assertEqual(M.parseZones('').length, 0, 'elsewhen treats a blank setting as a fresh install')
assertEqual(M.parseZones(undefined).length, 0, 'elsewhen treats an unset setting as a fresh install')
assertDeepEqual(M.parseZones('Europe/Rome'), [{ label: 'Rome', id: 'Europe/Rome' }], 'elsewhen labels a bare zone from its id')
assertEqual(M.labelForZoneId('America/Los_Angeles'), 'Los Angeles', 'elsewhen labels a zone id')
assertDeepEqual([0, 1, 2].map(i => M.indexOfZone(zones, zones[i].label, zones[i].id)), [0, 1, 2], 'elsewhen finds every row by label and zone')
assertEqual(M.indexOfZone(zones, 'Lagos', 'Africa/Lagos'), -1, 'elsewhen finds no row for an untracked globe city')
assertEqual(M.indexOfZone(zones, 'Tokyo', 'Asia/Osaka'), -1, 'elsewhen needs the zone to match')
assertEqual(M.indexOfZone(zones, 'Tokyo City', 'Asia/Tokyo'), -1, 'elsewhen needs the label to match')
assertEqual(M.indexOfZone(undefined, 'Paris', 'Europe/Paris'), -1, 'elsewhen tolerates a missing list')
const tokyoKey = M.factsKey(zones[1])
assertEqual(M.indexOfZoneKey(zones, tokyoKey), 1, 'elsewhen finds a focused city by key')
assertEqual(M.indexOfZoneKey(zones, ''), -1, 'elsewhen treats an empty key as no focus')
assertEqual(M.indexOfZoneKey(null, tokyoKey), -1, 'elsewhen tolerates a missing list for a key')
const moved = M.moveZone(zones, 0, 2)
assertEqual(moved[M.indexOfZoneKey(moved, tokyoKey)].label, 'Tokyo', 'elsewhen focus follows the city through a reorder')
assertEqual(M.indexOfZoneKey(M.removeZoneAt(zones, 0), tokyoKey), 0, 'elsewhen focus follows the city through a removal')
assertEqual(M.indexOfZoneKey(M.removeZoneAt(zones, 1), tokyoKey), -1, 'elsewhen drops focus when the city is removed')
const four = M.parseZones('A|X/a, B|X/b, C|X/c, D|X/d')
assertEqual(labels(M.moveZone(four, 0, 1)), 'B,A,C,D', 'elsewhen moves a row down one')
assertEqual(labels(M.moveZone(four, 0, 3)), 'B,C,D,A', 'elsewhen moves a row to the end')
assertEqual(labels(M.moveZone(four, 3, 0)), 'D,A,B,C', 'elsewhen moves a row to the front')
assert(M.moveZone(four, 2, 2) === four && M.moveZone(four, -1, 2) === four && M.moveZone(four, 0, 9) === four, 'elsewhen ignores no-op and out-of-range moves')
assertEqual(labels(four), 'A,B,C,D', 'elsewhen leaves the original list untouched')
assertEqual(M.removeZoneAt([zones[0]], 0).length, 1, 'elsewhen never removes the last city')
assertEqual(labels(M.removeZone(zones, 'Asia/Tokyo')), 'Paris,New York', 'elsewhen removes a city by zone')
const catalog = M.zoneOptions('America/Los_Angeles\nAsia/Tokyo', [])
const sharing = catalog.filter(o => o.value === 'America/Los_Angeles')
assert(sharing.length > 1 && new Set(sharing.map(o => o.label)).size === sharing.length, 'elsewhen offers several named cities for one zone')
assertEqual(M.addZone([], 'America/Los_Angeles', 'Oakland')[0].label, 'Oakland', 'elsewhen keeps the chosen city name')
assertEqual(M.addZone([], 'America/Los_Angeles', '')[0].label, 'Los Angeles', 'elsewhen falls back to the zone name')
assertEqual(M.addZone(M.addZone([], 'America/Los_Angeles', 'Oakland'), 'America/Los_Angeles', 'Las Vegas').length, 2, 'elsewhen tracks two cities in one zone')
const roundTrip = list => M.parseZones(M.serializeZones(list)).map(z => z.label + '@' + z.id)
assertDeepEqual(roundTrip(M.addZone([], 'Asia/Tokyo', 'Tokyo, Japan')), ['Tokyo Japan@Asia/Tokyo'], 'elsewhen keeps a comma out of the setting')
assertDeepEqual(roundTrip(M.addZone([], 'Asia/Tokyo', 'Tokyo|HQ')), ['Tokyo HQ@Asia/Tokyo'], 'elsewhen keeps a pipe out of the setting')
assertEqual(M.addZone([], 'Asia/Tokyo', ',|,')[0].label, 'Tokyo', 'elsewhen falls back when a label is only delimiters')
assertEqual(M.addZone([], 'evil id, with|pipe', 'x').length, 0, 'elsewhen refuses an id that cannot name a zone')
const unchanged = []
assert(M.addZone(unchanged, 'bad id', 'x') === unchanged, 'elsewhen returns the same list when refusing')
assertEqual(M.addZone([], 'Asia/Tokyo', '<img src=x>')[0].label, '<img src=x>', 'elsewhen keeps markup as plain text')
const remaining = M.zoneOptions('America/Los_Angeles', M.addZone([], 'America/Los_Angeles', 'Oakland'))
assert(!remaining.some(o => o.label === 'Oakland') && remaining.some(o => o.label === 'Las Vegas'), 'elsewhen drops only the tracked city from the picker')
const options = M.zoneOptions('Europe/Paris\nAmerica/Santiago\nAmerica/Argentina/Buenos_Aires', [])
assertEqual(M.searchZones(options, 'par', 6)[0].label, 'Paris', 'elsewhen ranks city-name prefixes first')
assert(M.searchZones(options, 'argentina', 6).some(o => o.label === 'Buenos Aires'), 'elsewhen searches zone ids too')
assertEqual(M.moveSelection(2, 1, 5), 3, 'elsewhen moves the selection down')
assertEqual(M.moveSelection(4, 1, 5), 0, 'elsewhen wraps the selection past the end')
assertEqual(M.moveSelection(0, -1, 5), 4, 'elsewhen wraps the selection past the start')
assertEqual(M.moveSelection(3, 1, 0), 0, 'elsewhen resets the selection in an empty list')
const home = ['Copenhagen', 'Europe/Copenhagen', 55.68, 12.57, 0]
const merged = M.mergeCities(
home,
[['copenhagen', 'Europe/Copenhagen', 55, 12, 1], ['Paris', 'Europe/Paris', 48.86, 2.35, 1], ['Nowhere', 'X/y', null, null, 2]],
[['Paris', 'Europe/Paris', 1, 1, 0], ['Oakland', 'America/Los_Angeles', 37.8, -122.3, 0], ['Pending', 'Asia/Tokyo', null, null, 0]],
[['Lagos', 'Africa/Lagos', 6.5, 3.4, 0], ['Unplaced', 'Asia/Dubai', undefined, undefined, 0]]
)
assertDeepEqual(merged.map(c => c[0]), ['Copenhagen', 'Paris', 'Nowhere', 'Oakland', 'Lagos'], 'elsewhen merges globe cities with home first and names deduplicated')
assertEqual(merged[1][2], 48.86, 'elsewhen keeps the first city of a name')
assertDeepEqual(M.mergeCities(['Home', 'X/y', undefined, undefined, 0], [['Paris', 'Europe/Paris', 1, 2, 1]], [], []).map(c => c[0]), ['Paris'], 'elsewhen leaves out a home without coordinates')
assertDeepEqual(M.mergeCities([], [], [], []), [], 'elsewhen merges nothing into nothing')
// ---- globe transition and row drag
assertEqual(M.knockAt(0, 0), 0, 'elsewhen rows start in place')
assertEqual(M.knockAt(0, 1), 1, 'elsewhen rows end fully knocked aside')
assertEqual(M.knockAt(2, 0.18), 0, 'elsewhen later rows wait their turn')
assertEqual(M.knockAt(10, 0.75), 0.5, 'elsewhen caps the stagger at half the zoom')
assertDeepEqual([M.knockX(0, 1, 95), M.knockX(1, 1, 95)], [-95, 95], 'elsewhen throws rows to alternating sides')
assertEqual(M.knockY(0, 0.5, 100), 25, 'elsewhen rows accelerate as they fall')
assertDeepEqual([M.knockTilt(0, 1), M.knockTilt(1, 1)], [-14, 14], 'elsewhen tilts rows to alternating sides')
assert(near(M.knockShrink(0, 1), 0.82), 'elsewhen shrinks knocked rows')
assertEqual(M.dragTargetFor(-1, 50, 40, -1, 4), -1, 'elsewhen has no drag target without a drag')
assertEqual(M.dragTargetFor(1, 50, 0, -1, 4), -1, 'elsewhen has no drag target before rows are measured')
assertEqual(M.dragTargetFor(1, 10, 40, -1, 4), 1, 'elsewhen holds the slot for a small drag')
assertEqual(M.dragTargetFor(1, 50, 40, -1, 4), 2, 'elsewhen moves the target past the threshold')
assertEqual(M.dragTargetFor(1, 60, 40, 3, 4), 3, 'elsewhen holds the target near a boundary')
assertEqual(M.dragTargetFor(1, 55, 40, 3, 4), 2, 'elsewhen moves the target back once clearly past')
assertEqual(M.dragTargetFor(0, 400, 40, -1, 4), 3, 'elsewhen clamps the target to the last row')
assertEqual(M.dragTargetFor(2, -400, 40, -1, 4), 0, 'elsewhen clamps the target to the first row')
assertDeepEqual([0, 1, 2, 3].map(i => M.rowShift(i, 1, 3, 70, 40)), [0, 70, -40, -40], 'elsewhen steps rows up under a downward drag')
assertDeepEqual([0, 1, 2, 3].map(i => M.rowShift(i, 3, 1, -70, 40)), [0, 40, 40, -70], 'elsewhen steps rows down under an upward drag')
assertDeepEqual([0, 1].map(i => M.rowShift(i, -1, -1, 0, 40)), [0, 0], 'elsewhen shifts nothing without a drag')
assertDeepEqual(M.mix({ r: 0, g: 0, b: 0 }, { r: 1, g: 0.5, b: 0.2 }, 0.5), { r: 0.5, g: 0.25, b: 0.1, a: 1 }, 'elsewhen mixes two colors opaquely')
assertDeepEqual(M.mix({ r: 0.2, g: 0.4, b: 0.6, a: 0.3 }, { r: 1, g: 1, b: 1 }, 0), { r: 0.2, g: 0.4, b: 0.6, a: 1 }, 'elsewhen mix at zero is the base color, opaque')
// ---- the strip's arrows and chips
const BAR = 567, BOX = 14, TUCK = 3, MARK = 10, SLACK = 4
assertEqual(M.arrowBox(0.26, BAR, BOX, TUCK, true), 136, 'elsewhen puts the sunrise arrow before the band')
assertEqual(M.arrowBox(0.81, BAR, BOX, TUCK, false), 456, 'elsewhen puts the sunset arrow after the band')
assert(M.arrowBox(0.26, BAR, BOX, TUCK, true) + BOX / 2 < 0.26 * BAR, 'elsewhen keeps the sunrise glyph before its crossing')
assert(M.arrowBox(0.81, BAR, BOX, TUCK, false) + BOX / 2 > 0.81 * BAR, 'elsewhen keeps the sunset glyph after its crossing')
assertEqual(M.arrowBox(0, BAR, BOX, TUCK, true), 0, 'elsewhen holds an arrow on the start of the bar')
assertEqual(M.arrowBox(1, BAR, BOX, TUCK, false), BAR - BOX, 'elsewhen holds an arrow on the end of the bar')
assertEqual(M.arrowCovered(136, BOX, 143, MARK, SLACK), true, 'elsewhen hides an arrow under the marker')
assertEqual(M.arrowCovered(136, BOX, 160, MARK, SLACK), false, 'elsewhen shows an arrow the marker has passed')
const SUNRISE = 0, SUNSET = 1
const click = (atPress, slot, tapFirst) => tapFirst
? M.chipAfterRelease(M.chipAfterTap(atPress, slot), atPress)
: (M.chipAfterRelease(atPress, atPress), M.chipAfterTap(atPress, slot))
for (const tapFirst of [true, false]) {
const order = tapFirst ? 'tap first' : 'release first'
assertDeepEqual(
[click(M.NO_CHIP, SUNRISE, tapFirst), click(SUNRISE, SUNRISE, tapFirst), click(SUNRISE, SUNSET, tapFirst), click(SUNSET, SUNRISE, tapFirst)],
[SUNRISE, M.NO_CHIP, SUNSET, SUNRISE],
`elsewhen chips open, close and swap (${order})`
)
}
assertEqual(M.chipAfterRelease(SUNSET, SUNSET), M.NO_CHIP, 'elsewhen closes a chip on a click elsewhere')
assertEqual(M.chipAfterRelease(SUNRISE, M.NO_CHIP), SUNRISE, 'elsewhen keeps a chip opened during the same press')
// ---- weather and units
assertDeepEqual(
[0, 1, 2, 3, 45, 48, 51, 57, 61, 67, 80, 82, 95, 99, 71, 77, 85, 86, '61'].map(M.weatherKind),
['sunny', 'sunny', 'partly', 'cloudy', 'cloudy', 'cloudy', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'snow', 'snow', 'snow', 'snow', 'rain'],
'elsewhen collapses WMO codes to five kinds'
)
assertDeepEqual([null, undefined, '', 'rain', 7].map(M.weatherKind), ['', '', '', '', ''], 'elsewhen never turns a missing reading into a sun')
const kinds = ['', 'sunny', 'partly', 'cloudy', 'rain', 'snow']
assert([...Array(100).keys()].every(c => kinds.includes(M.weatherKind(c))), 'elsewhen maps every code 0..99 to a known kind')
assertDeepEqual(
[M.resolveUnits('C', 'F'), M.resolveUnits('F', 'C'), M.resolveUnits('c', 'F'), M.resolveUnits(' f ', 'C')],
['C', 'F', 'C', 'F'],
'elsewhen honors an explicit temperature unit'
)
assertDeepEqual(
[M.resolveUnits('', 'C'), M.resolveUnits('', 'F'), M.resolveUnits(undefined, 'C'), M.resolveUnits(null, 'F'), M.resolveUnits('kelvin', 'C'), M.resolveUnits('', 'wat')],
['C', 'F', 'C', 'F', 'C', 'C'],
'elsewhen follows the system unit otherwise'
)
assertDeepEqual(
[M.formatTemp(0, 'C'), M.formatTemp(0, 'F'), M.formatTemp(100, 'F'), M.formatTemp(37, 'F'), M.formatTemp(-17.8, 'F'), M.formatTemp(21.5, 'C'), M.formatTemp(null, 'C')],
['0°C', '32°F', '212°F', '99°F', '0°F', '22°C', ''],
'elsewhen formats temperatures'
)
assertEqual(M.tempLabel({ c: 20 }, 'C'), '20°C', 'elsewhen labels a city temperature')
assertEqual(M.tempLabel(undefined, 'C'), '', 'elsewhen labels a missing temperature as nothing')
assertDeepEqual(
['h:mm AP', 'h:mm ap', "h'h'mm AP", 'AP h:mm', 'h:mm Ap'].map(M.usesTwentyFourHour),
[false, false, false, false, false],
'elsewhen reads a designator as a twelve-hour clock'
)
assertDeepEqual(
['HH:mm', 'HH:mm:ss', "H'h'mm", 'H:mm', 'H.mm', ''].map(M.usesTwentyFourHour),
[true, true, true, true, true, true],
'elsewhen reads no designator as a twenty-four-hour clock'
)
assertDeepEqual(
[M.resolveHour24(true, false), M.resolveHour24(false, true), M.resolveHour24('true', false), M.resolveHour24('false', true), M.resolveHour24('24', false), M.resolveHour24('12', true)],
[true, false, true, false, true, false],
'elsewhen honors an explicit clock setting'
)
assertDeepEqual(
[M.resolveHour24('', true), M.resolveHour24('', false), M.resolveHour24(undefined, true), M.resolveHour24(null, true), M.resolveHour24('maybe', true)],
[true, false, true, true, true],
'elsewhen follows the system clock otherwise'
)
// ---- first run
const HOMES = [
'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Europe/Paris',
'Europe/Copenhagen', 'Asia/Tokyo', 'Asia/Kolkata', 'Australia/Sydney',
'America/Sao_Paulo', 'Africa/Nairobi', 'Pacific/Honolulu', 'Pacific/Auckland',
'Atlantic/Reykjavik', 'Asia/Kathmandu', 'UTC'
]
const probedOffsets = M.parseProbe(probe([...new Set(M.seedCandidateZones().concat(HOMES))]))
const offsets = {}
for (const id in probedOffsets) offsets[id] = probedOffsets[id].offsetMinutes
const dial = (a, b) => { const d = Math.abs(a - b) % 1440; return Math.min(d, 1440 - d) }
const known = new Set(M.SEED_CANDIDATES.map(c => c.label))
const seedProblems = []
for (const home of HOMES) {
const list = M.seedZones({ label: M.labelForZoneId(home), id: home }, offsets, 4)
const ids = list.map(z => z.id)
const offs = ids.map(id => offsets[id])
const east = offs.map(o => ((o - offs[0]) % 1440 + 1440) % 1440)
let closest = 1e9
for (let i = 1; i < offs.length; i++)
for (let j = i + 1; j < offs.length; j++) closest = Math.min(closest, dial(offs[i], offs[j]))
const round = M.parseZones(M.serializeZones(list))
const checks = {
'five cities': list.length === 5,
'home first': ids[0] === home,
'no repeated zone': new Set(ids).size === ids.length,
'no repeated name': new Set(list.map(z => z.label.toLowerCase())).size === list.length,
'three hours from home': offs.slice(1).every(o => dial(o, offs[0]) >= 180),
'three hours apart': closest >= 180,
'sorted eastward': east.slice(1).every((e, i) => i === 0 || e >= east[i]),
'from the curated list': list.slice(1).every(z => known.has(z.label)),
'round trips': round.length === 5 && round.every((z, i) => z.id === ids[i])
}
for (const name in checks) if (!checks[name]) seedProblems.push(`${home}: ${name}`)
}
assertDeepEqual(seedProblems, [], 'elsewhen seeds four well-spread cities around every home')
assertEqual(M.seedZones({ label: 'Nowhere', id: 'Not/AZone' }, offsets, 4).length, 1, 'elsewhen seeds only home when its offset is unknown')
assertEqual(M.pickSeedZones({ label: 'X', id: 'UTC' }, {}, 4).length, 0, 'elsewhen seeds nothing without offsets')
// ---- coordinates and weather
const tab = '#comment\nUS\t+404251-0740023\tAmerica/New_York\nIN\t+2232+08822\tAsia/Kolkata\n'
assertDeepEqual(M.zoneTabCoords(tab, 'America/New_York'), { lat: 40.7142, lon: -74.0064 }, 'elsewhen reads seconds-precision zone1970 coordinates')
assertDeepEqual(M.zoneTabCoords(tab, 'Asia/Kolkata'), { lat: 22.5333, lon: 88.3667 }, 'elsewhen reads minutes-precision zone1970 coordinates')
assertEqual(M.zoneTabCoords(tab, 'Nowhere/Else'), null, 'elsewhen has no fallback for an unknown zone')
const geocoded = JSON.stringify({ results: [
{ latitude: 34.0, longitude: -98.4, timezone: 'America/Chicago' },
{ latitude: 26.4, longitude: -80.1, timezone: 'America/New_York' }
] })
assertDeepEqual(M.pickGeocode(geocoded, 'America/New_York'), { place: { lat: 26.4, lon: -80.1 }, answered: true }, 'elsewhen prefers a geocode in the row\'s own zone')
assertDeepEqual(M.pickGeocode(geocoded, 'Asia/Tokyo'), { place: { lat: 34.0, lon: -98.4 }, answered: true }, 'elsewhen falls back to the top geocode')
assertDeepEqual(M.pickGeocode('{}', 'Asia/Tokyo'), { place: null, answered: true }, 'elsewhen treats no results as an answer')
assertDeepEqual(M.pickGeocode('', 'Asia/Tokyo'), { place: null, answered: false }, 'elsewhen treats a failed request as unanswered')
assert(M.geocodeUrl('São Paulo').endsWith('name=S%C3%A3o%20Paulo'), 'elsewhen URL-encodes the geocoded label')
assertEqual(M.forecastUrl([{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }]).split('&').filter(p => /^l(at|ong)itude=/.test(p)).join('&'), 'latitude=1,3&longitude=2,4', 'elsewhen batches every point into one forecast')
assertDeepEqual(M.parseForecast('{"current":{"temperature_2m":14.33,"weather_code":3}}', ['a'], 5), { a: { c: 14.3, w: 3, at: 5 } }, 'elsewhen reads a single-point forecast')
assertDeepEqual(M.parseForecast('[{"current":{"temperature_2m":1,"weather_code":0}},{"current":{}}]', ['a', 'b'], 5), { a: { c: 1, w: 0, at: 5 } }, 'elsewhen skips points without a reading')
assertDeepEqual(M.parseForecast('', ['a'], 5), {}, 'elsewhen keeps nothing from a failed forecast')
assert(M.weatherStale(undefined, 0) && M.weatherStale({ at: 0 }, 20 * 60 * 1000) && !M.weatherStale({ at: 0 }, 60 * 1000), 'elsewhen refetches weather after twenty minutes')
assertDeepEqual(
M.mergeFacts(['a', 'b', 'c'], { a: { lat: 1, lon: 2 }, b: null }, { a: { c: 3, w: 0, at: 9 }, b: { c: 4, w: null } }),
{ a: { lat: 1, lon: 2, c: 3, w: 0 }, b: { c: 4 }, c: {} },
'elsewhen merges coordinates and weather, leaving out unknown fields'
)
JS
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
# /usr/bin/qmltestrunner is the Qt 5 binary, which exits 0 having run nothing.
qml_test_runner=""
for candidate in /usr/lib/qt6/bin/qmltestrunner /usr/lib/qt6/qmltestrunner; do
if [[ -x $candidate ]]; then
qml_test_runner=$candidate
break
fi
done
if [[ -z $qml_test_runner ]]; then
skip "qt6 qmltestrunner not found; skipping elsewhen pointer tests"
exit 0
fi
output=$(QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software "$qml_test_runner" -input "$SHELL_TEST_DIR/elsewhen/qml" -o -,txt 2>&1) ||
fail "elsewhen pointer tests" "$output"
pass "elsewhen pointer tests"
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const { Sun } = requireFromRoot('test/shell.d/elsewhen/solar.js')
const noonAt = ([y, mo, d], offset) => Date.UTC(y, mo - 1, d, 12) - offset * 60000
const within = (got, want, slack) => Math.abs(got - want) <= slack
// Open-Meteo's published sunrise and sunset in UTC:
// name, lat, lon, local date, UTC offset in minutes, rise, set, minutes of slack.
// Past 64 north the sun meets the horizon at a shallow angle and needs more slack.
const REFERENCE = [
['Chicago', 41.85, -87.65, [2026, 8, 31], -300, '2026-08-31T11:15', '2026-09-01T00:26', 2],
['Auckland', -36.85, 174.76, [2026, 8, 31], 720, '2026-08-30T18:43', '2026-08-31T05:59', 2],
['Copenhagen', 55.68, 12.57, [2026, 8, 31], 120, '2026-08-31T04:13', '2026-08-31T18:07', 2],
['Tokyo', 35.69, 139.69, [2026, 8, 31], 540, '2026-08-30T20:12', '2026-08-31T09:11', 2],
['Quito', -0.22, -78.51, [2026, 8, 31], -300, '2026-08-31T11:10', '2026-08-31T23:17', 2],
['Kashgar', 39.47, 75.99, [2026, 8, 31], 480, '2026-08-31T00:23', '2026-08-31T13:29', 2],
['Reykjavik', 64.15, -21.94, [2025, 12, 21], 0, '2025-12-21T11:21', '2025-12-21T15:30', 2],
['Sydney', -33.87, 151.21, [2025, 12, 21], 660, '2025-12-20T18:40', '2025-12-21T09:05', 2],
['Nairobi', -1.29, 36.82, [2026, 3, 20], 180, '2026-03-20T03:36', '2026-03-20T15:43', 2],
['Anchorage', 61.22, -149.90, [2026, 6, 21], -480, '2026-06-21T12:19', '2026-06-22T07:43', 2],
['Ushuaia', -54.80, -68.30, [2026, 6, 21], -180, '2026-06-21T12:58', '2026-06-21T20:11', 2],
['Nuuk', 64.18, -51.72, [2026, 8, 31], -120, '2026-08-31T08:06', '2026-08-31T22:47', 5],
['Anadyr', 64.73, 177.51, [2026, 8, 31], 720, '2026-08-30T16:47', '2026-08-31T07:35', 5]
]
const misses = []
for (const [name, lat, lon, date, offset, rise, set, slack] of REFERENCE) {
const times = Sun.sunTimes(lat, lon, noonAt(date, offset), offset)
if (times.kind !== 'normal') { misses.push(`${name} has no sunrise`); continue }
const riseOff = Math.abs(times.riseMs - Date.parse(rise + 'Z')) / 60000
const setOff = Math.abs(times.setMs - Date.parse(set + 'Z')) / 60000
if (riseOff > slack) misses.push(`${name} sunrise out by ${riseOff.toFixed(1)} min`)
if (setOff > slack) misses.push(`${name} sunset out by ${setOff.toFixed(1)} min`)
}
assertDeepEqual(misses, [], 'elsewhen matches published sunrise and sunset times')
// ---- the poles
const longyear = (date, offset) => Sun.sunTimes(78.22, 15.63, noonAt(date, offset), offset)
const midnightSun = longyear([2026, 6, 21], 120)
const polarNight = longyear([2025, 12, 21], 60)
assertEqual(midnightSun.kind, 'midnightSun', 'elsewhen knows the midnight sun')
assertEqual(polarNight.kind, 'polarNight', 'elsewhen knows the polar night')
assertDeepEqual(Sun.litSpans(midnightSun), [{ x0: 0, x1: 1 }], 'elsewhen lights the whole bar under the midnight sun')
assertDeepEqual(Sun.litSpans(polarNight), [], 'elsewhen lights nothing in the polar night')
assert(Sun.litAt(midnightSun, 3 * 60) && !Sun.litAt(polarNight, 12 * 60), 'elsewhen reads daylight at the poles')
assertEqual(Sun.eventMark(midnightSun.riseMinutes), null, 'elsewhen marks no sunrise that does not happen')
// ---- the strip
const chicago = Sun.sunTimes(41.85, -87.65, noonAt([2026, 8, 31], -300), -300)
const span = Sun.litSpans(chicago)
assertEqual(span.length, 1, 'elsewhen draws one band for an ordinary city')
assert(within(span[0].x0 * 1440, 375, 2) && within(span[0].x1 * 1440, 1166, 2), 'elsewhen draws the band from sunrise to sunset on the local clock')
assert(within(chicago.dayMinutes, 791, 2), 'elsewhen measures the length of the day')
assertDeepEqual([5, 12, 22, 0].map(h => Sun.litAt(chicago, h * 60)), [false, true, false, false], 'elsewhen reads daylight through the day')
const kashgar = Sun.sunTimes(39.47, 75.99, noonAt([2026, 8, 31], 480), 480)
assertEqual(Sun.litSpans(kashgar).length, 1, 'elsewhen draws one band for a zone far from its sun')
assert(within(kashgar.riseMinutes, 503, 2) && within(kashgar.setMinutes, 1289, 2), 'elsewhen keeps a far-off sun on its own clock')
const solstice = Sun.sunTimes(64.15, -21.94, noonAt([2026, 6, 21], 0), 0)
const ends = Sun.litSpans(solstice)
assert(solstice.setMinutes > 1440, 'elsewhen sets the Reykjavik solstice sun after midnight')
assertEqual(ends.length, 2, 'elsewhen lights a bar at both ends')
assert(within(ends[0].x0 * 1440, 0, 0.01) && within(ends[0].x1 * 1440, 4, 1), 'elsewhen lights the minutes after midnight')
assert(within(ends[1].x0 * 1440, 175, 1) && within(ends[1].x1 * 1440, 1440, 0.01), 'elsewhen lights from sunrise to the end of the bar')
assertDeepEqual([0, 60, 180].map(m => Sun.litAt(solstice, m)), [true, false, true], 'elsewhen reads daylight on a bar lit at both ends')
assertEqual(Sun.eventMark(solstice.setMinutes), null, 'elsewhen marks no sunset past midnight')
assertDeepEqual([-30, 1500, 0, 1440].map(Sun.eventMark), [null, null, 0, 1], 'elsewhen marks only events on the bar')
JS
+79
View File
@@ -0,0 +1,79 @@
import QtQuick
import QtTest
// The daylight strip's arrows over its scrub MouseArea, with synthetic mouse
// events: a click on an arrow must not scrub the clocks.
Item {
id: root
width: 300
height: 40
property int barPresses: 0
property int barReleases: 0
property int barCancels: 0
property int arrowTaps: 0
function reset() {
barPresses = 0; barReleases = 0; barCancels = 0; arrowTaps = 0
}
MouseArea {
id: bar
anchors.fill: parent
preventStealing: true
onPressed: root.barPresses++
onReleased: root.barReleases++
onCanceled: root.barCancels++
}
Item {
id: arrow
x: 100
width: 14
height: parent.height
z: 1
visible: true
TapHandler { onTapped: root.arrowTaps++ }
}
TestCase {
name: "ArrowsOverTheScrubBar"
when: windowShown
// A TapHandler's passive grab does not let the press through to the bar.
function test_a_click_on_the_arrow_does_not_reach_the_bar() {
root.reset()
mouseClick(root, arrow.x + arrow.width / 2, 20)
compare(root.arrowTaps, 1, "the arrow's tap fires")
compare(root.barPresses, 0, "and the bar underneath sees no press at all")
compare(root.barReleases, 0, "nor a release")
}
function test_a_click_on_the_bar_is_a_press_on_the_bar() {
root.reset()
mouseClick(root, 20, 20)
compare(root.arrowTaps, 0, "no tap away from the arrow")
compare(root.barPresses, 1, "the bar gets its own press")
compare(root.barReleases, 1, "and its own release")
}
// Arrows hide off-row and under the now-marker; the bar must still work.
function test_a_hidden_arrow_lets_the_bar_through() {
root.reset()
arrow.visible = false
mouseClick(root, arrow.x + arrow.width / 2, 20)
arrow.visible = true
compare(root.arrowTaps, 0, "an invisible arrow is not tapped")
compare(root.barPresses, 1, "the press belongs to the bar")
}
// Past the drag threshold the handler gives up, so a drag can still scrub.
function test_a_drag_from_the_arrow_is_not_a_tap() {
root.reset()
mousePress(root, arrow.x + arrow.width / 2, 20)
mouseMove(root, arrow.x + 60, 20)
mouseRelease(root, arrow.x + 60, 20)
compare(root.arrowTaps, 0, "a drag is not a tap")
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// GlobeModel.js and Sun.js keep QML's .pragma/.import headers, so node
// strips those lines and evaluates the rest.
const fs = require('fs')
const path = require('path')
const dir = path.join(__dirname, '../../../shell/plugins/panels/elsewhen')
function load(file, scope) {
const source = fs.readFileSync(path.join(dir, file), 'utf8').replace(/^\.(pragma|import) .*$/gm, '')
const module = { exports: {} }
new Function('module', ...Object.keys(scope), source)(module, ...Object.values(scope))
return module.exports
}
const Solar = load('GlobeModel.js', {})
const Sun = load('Sun.js', { Solar })
module.exports = { Solar, Sun }