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:
1 parent
5c7cef1e78
commit
e7218e36f2
37 files changed
+6780
-155
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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}¤t=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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user