Add shell plugin model tests

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:18:39 +02:00
parent 4277f5346b
commit 829c1fa4f7
61 changed files with 3236 additions and 1168 deletions
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const audio = requireFromRoot('shell/plugins/audio/AudioModel.js')
assert(audio.isPlaybackStream({ isStream: true, isSink: true }), 'audio detects sink-backed playback streams')
assert(audio.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'audio detects typed playback streams')
assert(!audio.isPlaybackStream({ isStream: false, isSink: true }), 'audio rejects non-stream playback nodes')
assert(audio.isAudioSource({ audio: {} }), 'audio detects nodes with audio as sources')
assert(audio.isAudioSource({ type: 'Audio/Source' }), 'audio detects typed source nodes')
assertEqual(audio.outputVolumeName(0, false), 'Silenced', 'audio labels silent output')
assertEqual(audio.outputVolumeName(0.9, false), 'Party mode', 'audio labels loud output')
assertEqual(audio.outputVolumeName(0.5, true), 'Muted', 'audio labels muted output')
assertDeepEqual(audio.parseSinkAvailability('alsa_output\t1\nhdmi_output\t0\n'), { alsa_output: true, hdmi_output: false }, 'audio parses sink availability')
assertEqual(audio.friendlyDeviceLabel('Built-in Audio Speakers Output'), 'Speakers', 'audio cleans device labels')
assertEqual(
audio.nodeLabel({ ready: true, properties: { 'node.nick': 'Built-in Audio Microphones Input' }, name: 'alsa_input' }),
'Microphone',
'audio chooses friendly node labels'
)
const headphones = { ready: true, name: 'bluez_output.airpods', properties: { 'device.product.name': 'AirPods Headphones' } }
assert(audio.isHeadphones(headphones), 'audio detects headphone devices')
assertEqual(audio.sinkGlyph(headphones), '󰋋', 'audio uses headphone sink glyph')
assert(audio.sourceGlyph({ ready: true, properties: { 'device.icon-name': 'camera-webcam' } }).length > 0, 'audio maps webcam source glyph')
assertEqual(audio.friendlyStreamLabel('spotify'), 'Spotify', 'audio normalizes known stream labels')
assert(audio.streamRepresentsMprisPlayer('Chromium', 'Chromium Browser'), 'audio matches related stream and MPRIS labels')
const players = [
{ identity: 'Spotify', canPlay: true, isPlaying: true, dbusName: 'org.mpris.MediaPlayer2.spotify' },
{ identity: 'Chromium', canPlay: true, isPlaying: false, dbusName: 'org.mpris.MediaPlayer2.chromium' }
]
const streams = [
{ ready: true, properties: { 'application.name': 'Chromium' } },
{ ready: true, properties: { 'application.name': 'audio-src' } }
]
assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'audio finds matching MPRIS labels')
assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams')
assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS')
assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player')
JS
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions')
assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions')
assertDeepEqual(bar.entrySettings({ id: 'omarchy.clock', format: 'HH:mm' }), { format: 'HH:mm' }, 'bar extracts entry settings')
assertEqual(bar.entryId({ id: 'omarchy.clock' }), 'omarchy.clock', 'bar extracts object entry ids')
assertEqual(bar.entryId('omarchy.clock'), 'omarchy.clock', 'bar extracts string entry ids')
const entries = [{ id: 'a' }, { id: 'omarchy.tray' }, { id: 'b' }]
assertDeepEqual(bar.pinTrayToInner(entries, 'left').map(bar.entryId), ['a', 'b', 'omarchy.tray'], 'bar pins tray to left inner edge')
assertDeepEqual(bar.pinTrayToInner(entries, 'right').map(bar.entryId), ['omarchy.tray', 'a', 'b'], 'bar pins tray to right inner edge')
assertEqual(bar.moduleString({ id: 'custom', label: 42 }, 'label', 'fallback'), '42', 'bar stringifies module settings')
assertEqual(bar.entryIndex(entries, 'b'), 2, 'bar finds entry indexes')
assertDeepEqual(bar.entriesBefore(entries, 'b').map(bar.entryId), ['a', 'omarchy.tray'], 'bar returns entries before target')
assertDeepEqual(bar.entriesAfter(entries, 'a').map(bar.entryId), ['omarchy.tray', 'b'], 'bar returns entries after target')
assertEqual(bar.expandPath('~/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands tilde paths')
assertEqual(bar.expandPath('$HOME/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands HOME paths')
assert(bar.customModuleSafeName('local.weather'), 'bar accepts safe custom module names')
assert(!bar.customModuleSafeName('../escape'), 'bar rejects path traversal custom module names')
assertEqual(bar.customModuleType({ id: 'custom', exec: 'date' }), 'command', 'bar infers command custom modules')
assertEqual(bar.customModuleType({ id: 'custom', source: '~/Custom.qml' }), 'qml', 'bar infers qml custom modules')
assertEqual(
bar.customModulePath({ id: 'local.weather' }, '/home/dhh', '/home/dhh/.config/omarchy'),
'/home/dhh/.config/omarchy/bar/modules/local.weather.qml',
'bar builds default custom module paths'
)
JS
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
echo "source test/shell.d/base-test.sh from a shell test; do not run it directly" >&2
exit 1
fi
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
SHELL_TEST_DIR="$ROOT/test/shell.d"
export ROOT
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
require_command() {
local command="$1"
command -v "$command" >/dev/null || fail "required command is available: $command"
}
run_node_test() {
require_command node
{
cat <<'JS_PRELUDE'
const path = require('path')
const root = process.env.ROOT
function fail(description, detail) {
if (detail) console.error(detail)
console.error(`not ok - ${description}`)
process.exit(1)
}
function pass(description) {
console.log(`ok - ${description}`)
}
function assert(condition, description, detail) {
if (!condition) fail(description, detail)
pass(description)
}
function assertEqual(actual, expected, description) {
assert(
actual === expected,
description,
`expected: ${expected}\nactual: ${actual}`
)
}
function assertDeepEqual(actual, expected, description) {
const actualJson = JSON.stringify(actual)
const expectedJson = JSON.stringify(expected)
assert(
actualJson === expectedJson,
description,
`expected: ${expectedJson}\nactual: ${actualJson}`
)
}
function requireFromRoot(relativePath) {
return require(path.join(root, relativePath))
}
JS_PRELUDE
cat
} | node
}
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js')
const discharging = 1
assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 13, 'battery rounds display percentage')
assertEqual(battery.batteryPercentage({ isPresent: false, percentage: 0.5 }), -1, 'battery reports missing battery')
assert(battery.isDischarging({ isPresent: true, state: discharging }, true, discharging), 'battery detects discharging state')
assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, discharging), 'battery requires on-battery state')
assertDeepEqual(
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false),
{ level: 8, notify: true, notifiedLowBattery: true },
'battery warns once under threshold'
)
assertDeepEqual(
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, true),
{ level: 8, notify: false, notifiedLowBattery: true },
'battery keeps low-battery notified state'
)
assertDeepEqual(
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, true),
{ level: 40, notify: false, notifiedLowBattery: false },
'battery clears notified state after recovery'
)
JS
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const bluetooth = requireFromRoot('shell/plugins/bluetooth/BluetoothModel.js')
assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names')
assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names')
assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels')
assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels')
const devices = [
{ name: 'Speaker', connected: false, paired: true, address: '2' },
{ name: 'Headphones', connected: true, address: '1' },
{ name: 'Keyboard', connected: false, address: '3' },
{ name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' },
{ name: 'Mouse', connected: false, trusted: true, address: '5' }
]
const lists = bluetooth.deviceLists(devices)
assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices')
assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label')
assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices')
assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning')
assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning')
assertDeepEqual(
bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'),
{ a: 'connecting', b: 'forgetting' },
'bluetooth adds pending actions immutably'
)
assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably')
JS
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const clipboard = requireFromRoot('shell/plugins/clipboard/ClipboardHistory.js')
assertDeepEqual(
clipboard.normalizeEntry('hello'),
{ type: 'text', text: 'hello' },
'clipboard normalizes string entries'
)
assertDeepEqual(
clipboard.normalizeEntry({ kind: 'image', path: '/tmp/a.png' }),
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' },
'clipboard normalizes image entries with default mime'
)
assertDeepEqual(
clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])),
[
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
{ type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }
],
'clipboard history parser drops invalid entries'
)
const history = [
{ type: 'text', text: 'old' },
{ type: 'text', text: 'new' },
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
]
assertDeepEqual(
clipboard.addEntry(history, { type: 'text', text: 'new' }, 100),
[
{ type: 'text', text: 'new' },
{ type: 'text', text: 'old' },
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
],
'clipboard addEntry moves duplicate text to front'
)
assertDeepEqual(
clipboard.displayRows(history, 'image', 50).map(row => ({ type: row.entryType, preview: row.previewText, mime: row.mime })),
[{ type: 'image', preview: 'Image', mime: 'image/png' }],
'clipboard display rows search image metadata'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'text', text: 'line one\nline two' }], '', 50)[0].previewText,
'line one line two',
'clipboard display rows collapse text whitespace'
)
assertDeepEqual(clipboard.displayRows(history, '', 0), [], 'clipboard display rows supports zero result limit')
assertDeepEqual(clipboard.addEntry(history, 'next', 0), [], 'clipboard addEntry supports zero history limit')
JS
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const emojis = requireFromRoot('shell/plugins/emojis/EmojiSearch.js')
const raw = fs.readFileSync(path.join(root, 'shell/plugins/emojis/emojis.json'), 'utf8')
const data = emojis.parseEmojis(raw)
assert(data.length > 1000, 'emoji dataset parses')
assertDeepEqual(emojis.parseEmojis('{'), [], 'invalid emoji JSON parses as empty list')
assertDeepEqual(emojis.parseEmojis('{"e":"nope"}'), [], 'non-array emoji JSON parses as empty list')
const fixture = [
{ e: 'a', k: 'grinning face smile happy' },
{ e: 'b', k: 'face with tears of joy joy tears' },
{ e: 'c', k: 'flag: united states us america' }
]
assertDeepEqual(
emojis.filterEmojis(fixture, ' JOY ').map(item => item.e),
['b'],
'emoji filtering trims and lowercases query'
)
assertDeepEqual(
emojis.filterEmojis(fixture, '', 2).map(item => item.e),
['a', 'b'],
'emoji filtering honors result limit'
)
assertDeepEqual(
emojis.filterEmojis(fixture, '', 0),
[],
'emoji filtering supports zero result limit'
)
assertEqual(
emojis.filterEmojis(data, 'face with tears')[0].e,
'\u{1F602}',
'emoji filtering finds face with tears of joy'
)
JS
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js')
assertEqual(idle.secondsFromConfig('42.9', 10), 42, 'idle floors configured seconds')
assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds')
assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid seconds')
assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data')
assertDeepEqual(
idle.eventParts({ parse: function(count) { return ['parsed', count] } }, 4),
['parsed', 4],
'idle prefers event parser when available'
)
assertDeepEqual(
idle.screensaverWindowsAfter({ a: true }, 'b', true),
{ windows: { a: true, b: true }, count: 2 },
'idle adds visible screensaver windows'
)
assertDeepEqual(
idle.screensaverWindowsAfter({ a: true, b: true }, 'a', false),
{ windows: { b: true }, count: 1 },
'idle removes closed screensaver windows'
)
assertDeepEqual(
idle.screensaverWindowsAfter({ a: true }, '', false),
{ windows: { a: true }, count: 1 },
'idle leaves screensaver windows unchanged without an address'
)
JS
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const picker = requireFromRoot('shell/plugins/image-picker/ImagePickerModel.js')
assertEqual(picker.nameForPath('/themes/nord-river.png'), 'nord-river', 'image picker strips directory and extension')
assertEqual(picker.labelForPath('/themes/nord_river.png'), 'Nord River', 'image picker builds display labels')
const rows = [
'/themes/a/nord-river.png\t/cache/nord-river.jpg',
'/themes/b/nord-river.png\t/cache/duplicate.jpg',
'/themes/a/gruvbox-dark.jpeg',
'',
'\t/cache/no-path.jpg',
'/themes/a/plain'
].join('\n')
const images = picker.loadRows(rows)
assertDeepEqual(
images,
[
{ filePath: '/themes/a/nord-river.png', fileName: 'nord-river.png', thumbnailPath: '/cache/nord-river.jpg' },
{ filePath: '/themes/a/gruvbox-dark.jpeg', fileName: 'gruvbox-dark.jpeg', thumbnailPath: '/themes/a/gruvbox-dark.jpeg' },
{ filePath: '/themes/a/plain', fileName: 'plain', thumbnailPath: '/themes/a/plain' }
],
'image picker parses rows and dedupes by file name'
)
assert(picker.itemMatches(images, 0, 'river'), 'image picker matches file names')
assert(picker.itemMatches(images, 1, 'Gruvbox Dark'), 'image picker matches labels case-insensitively')
assert(!picker.itemMatches(images, 2, 'river'), 'image picker rejects non-matching filters')
assertEqual(picker.firstMatchingIndex(images, 'plain'), 2, 'image picker finds first matching index')
assertEqual(picker.indexForSelectedImage(images, '/themes/a/gruvbox-dark.jpeg'), 1, 'image picker finds selected image')
assertEqual(picker.indexForSelectedImage(images, '/missing.png'), 0, 'image picker defaults selected image to first row')
assertEqual(picker.filteredPosition(images, 2, 'dark'), 1, 'image picker computes filtered position')
assertEqual(picker.selectedFilteredPosition(images, 2, 'dark'), 0, 'image picker selected filtered position falls back when selected is hidden')
assertEqual(picker.nextSelectedIndexForFilter(images, 0, 'dark'), 1, 'image picker moves selection to first match when filter hides current item')
JS
+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 search = requireFromRoot('shell/plugins/launcher/LauncherSearch.js')
const entries = [
{
name: 'Google Contacts',
genericName: 'Address Book',
comment: 'Manage contacts',
keywords: ['contacts', 'address book', 'people'],
id: 'google-contacts.desktop'
},
{
name: 'Calculator',
genericName: 'Calculator',
comment: 'Perform arithmetic, scientific or financial calculations',
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
id: 'org.gnome.Calculator.desktop'
},
{
name: 'OBS Studio',
genericName: 'Streaming/Recording Software',
comment: 'Free and Open Source Streaming/Recording Software',
keywords: ['streaming', 'recording', 'capture'],
id: 'com.obsproject.Studio.desktop'
},
{
name: 'Aether',
genericName: '',
comment: 'Minimal internet radio player',
keywords: ['audio', 'music', 'radio'],
id: 'io.github.taqi.aether.desktop'
},
{
name: 'Xournal++',
genericName: 'Notetaking',
comment: 'Take handwritten notes',
keywords: ['notes', 'pdf', 'annotation'],
id: 'com.github.xournalpp.xournalpp.desktop'
},
{
name: 'RustDesk',
genericName: 'Remote Desktop',
comment: 'Remote desktop control',
keywords: ['remote', 'desktop', 'control'],
id: 'com.rustdesk.RustDesk.desktop'
}
]
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
assert(
search.fuzzyScore(entries[1], 'contact') < 0,
'calculator does not match contact as a loose subsequence'
)
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
JS
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const media = requireFromRoot('shell/plugins/services/media/MediaModel.js')
assert(media.isProxyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld' }), 'media detects playerctld proxy by DBus name')
assert(media.isProxyPlayer({ desktopEntry: 'playerctld' }), 'media detects playerctld proxy by desktop entry')
assert(media.hasMetadata({ identity: 'Spotify' }), 'media detects identity metadata')
assert(media.hasTrackMetadata({ trackTitle: 'Track' }), 'media detects track metadata')
assert(media.playerCanControl({ canGoNext: true }), 'media detects controllable players')
assert(media.canHandleAction({ canTogglePlaying: true }, 'playPause'), 'media maps playPause capability')
assert(media.canCycleSource({ identity: 'Spotify', canPlay: true }), 'media detects cycleable sources')
assert(media.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'media detects playback streams')
assertEqual(media.streamLabelKey('PipeWire ALSA [Chromium]'), 'chromium', 'media normalizes stream labels')
assertEqual(
media.rawStreamLabel({ ready: true, properties: { 'application.name': 'Chromium' }, name: 'fallback' }),
'Chromium',
'media extracts raw stream labels'
)
assertEqual(
media.playerAppLabel({ dbusName: 'org.mpris.MediaPlayer2.spotify.instance42' }),
'spotify',
'media derives player app labels from DBus names'
)
assert(media.playerHasPlaybackStream(
{ desktopEntry: 'chromium' },
[{ ready: true, properties: { 'application.name': 'Chromium' } }]
), 'media matches players to playback streams')
assertEqual(media.playerKey({ dbusName: 'org.mpris.MediaPlayer2.spotify' }), 'org.mpris.MediaPlayer2.spotify', 'media derives stable player keys')
assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first')
assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages')
assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages')
JS
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
const parsed = menu.parseMenuJsonc(`
{
// comment
"items": {
"root": { "label": "Go" },
"style": { "label": "Style" },
"style.theme": {
"label": "Themes",
"aliases": "theme",
"keywords": "appearance appearance colors",
"action": "omarchy-theme-set"
},
},
}
`)
assertEqual(parsed.length, 3, 'menu parses JSONC with comments and trailing commas')
assertDeepEqual(
parsed.find(item => item.id === 'style.theme'),
{
id: 'style.theme',
parent: 'style',
kind: 'action',
icon: '',
label: 'Themes',
target: '',
keywords: 'appearance colors',
description: '',
action: 'omarchy-theme-set',
provider: '',
aliases: ['theme'],
when: '',
checked: ''
},
'menu normalizes parsed items'
)
const user = [
menu.normalizeItem('style.theme', { label: 'Theme picker', aliases: ['theme', 'colors'], action: 'custom-theme' }),
menu.normalizeItem('tools', { label: 'Tools' })
]
const merged = menu.mergeMenuSources(parsed, user)
assertEqual(merged.items['style.theme'].label, 'Theme picker', 'menu user entries override default entries')
assertEqual(merged.items['style.theme'].order, 2, 'menu preserves original order on override')
assert(merged.items.root, 'menu injects root when merging sources')
assertEqual(menu.slugify('Power Saver!'), 'power-saver', 'menu slugifies provider rows')
assertEqual(menu.pathFor(merged.items, 'style.theme'), 'Style Theme picker', 'menu builds item paths')
assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu builds parent paths')
assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects descendants')
assertEqual(menu.childCount(merged.items, merged.itemOrder, 'style'), 1, 'menu counts children')
assertEqual(menu.labelFor({ id: 'style.theme', label: 'Theme', checked: 'cmd' }, { 'style.theme': true }), 'Theme ✓', 'menu appends checked marker')
const entry = merged.items['style.theme']
assert(menu.matchesQuery(entry, 'theme', true), 'menu matches labels and aliases')
assert(menu.matchesQuery(entry, 'colors', true), 'menu matches aliases')
assert(!menu.matchesQuery(entry, 'missing', true), 'menu rejects missing terms')
assert(!menu.matchesQuery(entry, 'theme', false), 'menu hides invisible matches')
assert(menu.searchScore(merged.items, entry, 'theme') < menu.searchScore(merged.items, entry, 'appearance'), 'menu scores name matches above keyword matches')
assertDeepEqual(
menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'),
{
itemId: 'style.theme',
kind: 'action',
icon: '',
label: 'Theme picker',
target: 'style.theme',
detail: 'Style',
path: 'Style Theme picker',
childCount: 0,
action: 'custom-theme',
provider: '',
score: 12,
section: 'search'
},
'menu builds display rows'
)
JS
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const monitor = requireFromRoot('shell/plugins/monitor/MonitorModel.js')
assertEqual(monitor.clampBrightness(0), 1, 'monitor clamps minimum brightness')
assertEqual(monitor.clampBrightness(101), 100, 'monitor clamps maximum brightness')
assertEqual(monitor.clampBrightness(42.4), 42, 'monitor rounds brightness')
assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightness')
assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale')
assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale')
assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays')
assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays')
assertDeepEqual(
monitor.parseDisplays(JSON.stringify([
{ name: 'eDP-1', enabled: true },
{ name: 'HDMI-A-1', enabled: false },
{ name: 'DP-1', enabled: true }
])),
{
displays: [
{ name: 'eDP-1', enabled: true },
{ name: 'HDMI-A-1', enabled: false },
{ name: 'DP-1', enabled: true }
],
enabledDisplayCount: 2
},
'monitor parses display state'
)
assertDeepEqual(monitor.parseDisplays('{'), { displays: [], enabledDisplayCount: 0 }, 'monitor handles invalid display JSON')
JS
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const network = requireFromRoot('shell/plugins/network/NetworkModel.js')
assertDeepEqual(
network.parseNetworkStatus('wifi\tCafe WiFi\t78\t5200\n'),
{ kind: 'wifi', label: 'Cafe WiFi', signalStrength: 78, frequency: '5200' },
'network parses bar status'
)
assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal')
assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed')
assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed')
assertEqual(network.formatHeaderFreq('5200'), '5.2ghz', 'network formats wifi frequency')
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network header uses ethernet speed')
assertDeepEqual(
network.parseKeyValue('iface\twlan0\nrx_bytes\t100\ntx_bytes\t50\n'),
{ iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' },
'network parses detail key values'
)
assertDeepEqual(
network.throughputState({ prevIface: '', prevSampleTime: 0 }, { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, 10),
{ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10, downloadRate: 0, uploadRate: 0 },
'network seeds throughput state on first sample'
)
assertDeepEqual(
network.throughputState({ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10 }, { iface: 'wlan0', rx_bytes: '300', tx_bytes: '90' }, 12),
{ prevIface: 'wlan0', prevRxBytes: 300, prevTxBytes: 90, prevSampleTime: 12, downloadRate: 100, uploadRate: 20 },
'network computes throughput deltas'
)
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
const rows = network.sortWifiRows([
{ ssid: 'Open', connected: false, known: false, signal: 95 },
{ ssid: 'Known', connected: false, known: true, signal: 10 },
{ ssid: 'Connected', connected: true, known: true, signal: 20 }
])
assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'network sorts wifi rows by connection and known state')
assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section')
assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section')
const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 }
assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures')
JS
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js')
assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name')
assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon')
assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived')
assertEqual(
notifications.sanitizeBody('<img src="x">Hello', 'Slack', ''),
'Hello',
'notifications strip inline image tags'
)
assertEqual(
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
'Message body',
'notifications strip chromium leading origin links'
)
assertEqual(
notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''),
'Message body',
'notifications strip chromium leading origin text'
)
assertEqual(
notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''),
'https://example.com/path Message body',
'notifications keep non-browser leading origin text'
)
assert(notifications.summaryStartsWithGlyph('󰂚 Silenced'), 'notifications detect glyph-prefixed summaries')
assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed')
assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND')
assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND')
assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND')
assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND')
const notification = {
id: 12,
appName: 'Mail',
appIcon: 'mail',
summary: 42,
body: 'Body',
image: 'file:///tmp/mail.png',
hints: { 'omarchy-glyph': '!' },
urgency: 1
}
const snapshot = notifications.snapshotOf(notification, 12345)
assertDeepEqual(
{
id: snapshot.id,
originalId: snapshot.originalId,
app: snapshot.app,
appIcon: snapshot.appIcon,
summary: snapshot.summary,
body: snapshot.body,
image: snapshot.image,
glyph: snapshot.glyph,
urgency: snapshot.urgency,
timestamp: snapshot.timestamp
},
{
id: 12,
originalId: 12,
app: 'Mail',
appIcon: 'mail',
summary: '42',
body: 'Body',
image: 'file:///tmp/mail.png',
glyph: '!',
urgency: 1,
timestamp: 12345
},
'notifications create stable snapshots'
)
const history = notifications.parseHistory(JSON.stringify({
dnd: true,
pending: [
{ id: 1, originalId: 10, summary: 'old', timestamp: 100 },
{ id: 2, originalId: 10, summary: 'new', timestamp: 200 },
{ id: 3, originalId: 11, summary: 'other', timestamp: 150 }
],
past: [
{ id: 4, summary: 'past', timestamp: 50 }
],
entries: [
{ id: 5, summary: 'legacy', timestamp: 75 }
]
}), 1, 100)
assertEqual(history.dnd, true, 'notifications parse persisted DND state')
assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows')
assertDeepEqual(
history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })),
[
{ id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 },
{ id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 }
],
'notifications dedupe pending history by original id'
)
assertDeepEqual(
history.past.map(row => row.summary),
['legacy', 'past'],
'notifications merge legacy entries into past history'
)
assertDeepEqual(
notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending,
[],
'notifications history parser supports zero result cap'
)
assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON')
assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions')
assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension')
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
JS
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const osd = requireFromRoot('shell/plugins/osd/OsdModel.js')
assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to muted icon at zero percent')
assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases')
assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons')
assertDeepEqual(
osd.stateForShow('volume', '', '75', '100', '', '800'),
{
iconKey: 'volume',
maxValue: 100,
hasProgress: true,
value: 75,
message: '75%',
icon: osd.iconFor('volume', 75),
duration: 800
},
'osd builds progress state'
)
assertDeepEqual(
osd.stateForShow('media-pause', 'Paused', '', '100', '', 'nope'),
{
iconKey: 'media-pause',
maxValue: 100,
hasProgress: false,
value: 0,
message: 'Paused',
icon: osd.iconFor('media-pause', -1),
duration: 1200
},
'osd builds message state'
)
JS
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const pluginsDir = path.join(root, 'shell/plugins')
const kindEntryPoints = {
'bar': 'bar',
'bar-widget': 'barWidget',
'menu': 'menu',
'overlay': 'overlay',
'panel': 'panel',
'service': 'service'
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function walk(dir) {
const rows = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
rows.push(...walk(fullPath))
} else if (entry.isFile() && (entry.name === 'manifest.json' || entry.name.endsWith('.manifest.json'))) {
rows.push(fullPath)
}
}
return rows.sort()
}
function relativeFromPlugins(filePath) {
return path.relative(pluginsDir, filePath).split(path.sep).join('/')
}
function sourceDirForManifest(manifestPath) {
return path.dirname(manifestPath)
}
const errors = []
function check(condition, detail) {
if (!condition) errors.push(detail)
}
function assertSafeEntryPoint(manifest, manifestPath, key, value) {
const label = `${manifest.id} ${key} entry point`
check(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`)
check(!path.isAbsolute(value), `${label} must be relative`)
check(!String(value).split(/[\\/]+/).includes('..'), `${label} must stay inside plugin source`)
check(fs.existsSync(path.join(sourceDirForManifest(manifestPath), String(value))), `${label} file must exist`)
}
const manifests = walk(pluginsDir)
const manifestPaths = manifests.map(relativeFromPlugins)
const manifestSet = new Set(manifestPaths)
assert(manifests.length > 0, 'plugin manifests are present')
for (const entry of fs.readdirSync(pluginsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'services') continue
check(
manifestSet.has(`${entry.name}/manifest.json`),
`top-level plugin ${entry.name} must have a manifest`
)
}
const serviceRoot = path.join(pluginsDir, 'services')
for (const entry of fs.readdirSync(serviceRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
check(
manifestSet.has(`services/${entry.name}/manifest.json`),
`service plugin ${entry.name} must have a manifest`
)
}
for (const manifestPath of manifestPaths) {
const depth = manifestPath.split('/').length
check(depth >= 2 && depth <= 3, `${manifestPath} must be discoverable by PluginRegistry`)
}
const ids = new Set()
for (const manifestPath of manifests) {
const relativePath = relativeFromPlugins(manifestPath)
let manifest = null
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
} catch (error) {
errors.push(`${relativePath} must parse as JSON: ${error.message}`)
continue
}
check(isPlainObject(manifest), `${relativePath} must parse to an object`)
if (!isPlainObject(manifest)) continue
check(manifest.schemaVersion === 1, `${relativePath} must use schema version 1`)
for (const field of ['id', 'name', 'version', 'description']) {
check(typeof manifest[field] === 'string' && manifest[field].length > 0, `${manifest.id || relativePath} must have ${field}`)
}
check(String(manifest.id).startsWith('omarchy.'), `${manifest.id} must use the first-party namespace`)
check(!String(manifest.id).includes('/') && !String(manifest.id).includes('..'), `${manifest.id} must be safe as a plugin id`)
check(!ids.has(manifest.id), `${manifest.id} must be unique`)
ids.add(manifest.id)
check(Array.isArray(manifest.kinds) && manifest.kinds.length > 0, `${manifest.id} must declare plugin kinds`)
check(
JSON.stringify([...new Set(manifest.kinds || [])]) === JSON.stringify(manifest.kinds || []),
`${manifest.id} must not duplicate plugin kinds`
)
check(isPlainObject(manifest.entryPoints), `${manifest.id} must have an entryPoints object`)
for (const kind of manifest.kinds || []) {
check(kindEntryPoints[kind], `${manifest.id} must use supported plugin kind ${kind}`)
const entryPointKey = kindEntryPoints[kind]
check(manifest.entryPoints && manifest.entryPoints[entryPointKey], `${manifest.id} must declare ${entryPointKey} entry point`)
}
for (const key of Object.keys(manifest.entryPoints || {})) {
check(Object.values(kindEntryPoints).includes(key), `${manifest.id} entry point ${key} must be a supported key`)
assertSafeEntryPoint(manifest, manifestPath, key, manifest.entryPoints[key])
}
if (manifest.keepLoaded !== undefined) {
check(typeof manifest.keepLoaded === 'boolean', `${manifest.id} keepLoaded must be boolean when present`)
}
if ((manifest.kinds || []).includes('bar-widget')) {
check(isPlainObject(manifest.barWidget), `${manifest.id} must have barWidget metadata`)
for (const field of ['displayName', 'description', 'category']) {
check(
manifest.barWidget && typeof manifest.barWidget[field] === 'string' && manifest.barWidget[field].length > 0,
`${manifest.id} barWidget metadata must have ${field}`
)
}
check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`)
}
if (relativePath.endsWith('.manifest.json')) {
check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`)
}
}
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
JS
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const polkit = requireFromRoot('shell/plugins/polkit/PolkitModel.js')
assert(polkit.promptLooksFingerprint('Swipe your finger'), 'polkit detects fingerprint prompts')
assert(polkit.promptLooksFingerprint('fprintd verification'), 'polkit detects fprint prompts')
assert(!polkit.promptLooksFingerprint('Password:'), 'polkit ignores password prompts')
assert(
polkit.fingerprintFirstFromPamConfig(`
# comment
auth sufficient pam_fprintd.so
auth include system-auth
`),
'polkit detects fingerprint-first PAM config'
)
assert(
!polkit.fingerprintFirstFromPamConfig(`
account include system-auth
auth include system-auth
auth sufficient pam_fprintd.so
`),
'polkit detects password-first PAM config'
)
JS
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const power = requireFromRoot('shell/plugins/power/PowerModel.js')
const states = { Charging: 1, Discharging: 2, FullyCharged: 3, PendingCharge: 4 }
assertEqual(power.selectProfileIndex(0, 1, ['balanced', 'performance']), 1, 'power advances profile selection')
assertEqual(power.selectProfileIndex(1, 1, ['balanced', 'performance']), 1, 'power clamps profile selection')
assertDeepEqual(power.parseKeyValue('time\t2:00\nenergy\t42\n'), { time: '2:00', energy: '42' }, 'power parses key-value output')
assertDeepEqual(
power.parseProfiles('power-saver\t0\nbalanced\t1\nperformance\t0\n', 5),
{ profiles: ['power-saver', 'balanced', 'performance'], activeProfile: 'balanced', profileIndex: 2 },
'power parses profile output and clamps selection'
)
assert(power.profileIcon('performance').length > 0, 'power maps profile icons')
assertEqual(power.batteryFraction({ isPresent: true, percentage: 1.5 }), 1, 'power clamps battery fraction')
assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power detects threshold by pending charge state')
assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 0.1, timeToFull: 120 }, false, states), 'power detects threshold by stalled charging')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 1.0, timeToFull: 120 }, false, states), 'power does not flag active charging as threshold')
assertEqual(power.modeLabel({ isPresent: true, percentage: 1, state: states.FullyCharged }, false, states), 'Fully charged', 'power labels full battery')
assertEqual(power.modeLabel({ isPresent: true, percentage: 0.5, state: states.Discharging }, true, states), 'On battery', 'power labels battery mode')
assert(power.batteryIcon({ isPresent: true, percentage: 0.4, state: states.Charging }, false, states).length > 0, 'power maps battery icons')
JS
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const reminders = requireFromRoot('shell/plugins/reminders/ReminderFlowModel.js')
assertEqual(reminders.validMinutes('15'), '15', 'reminders accepts positive integer minutes')
assertEqual(reminders.validMinutes(' 5 '), '5', 'reminders trims minute input')
assertEqual(reminders.validMinutes('0'), '', 'reminders rejects zero minutes')
assertEqual(reminders.validMinutes('-5'), '', 'reminders rejects negative minutes')
assertEqual(reminders.validMinutes('1.5'), '', 'reminders rejects fractional minutes')
assertEqual(reminders.validMinutes('soon'), '', 'reminders rejects non-numeric minutes')
assertDeepEqual(
reminders.reminderArgs('10', 'Check the oven'),
['10', 'Check the oven'],
'reminders builds command args with message'
)
assertDeepEqual(
reminders.reminderArgs('10', ''),
['10'],
'reminders omits empty message arg'
)
assertDeepEqual(
reminders.reminderArgs('0', 'ignored'),
[],
'reminders command args are empty for invalid minutes'
)
JS
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const weather = requireFromRoot('shell/plugins/weather/WeatherModel.js')
assertDeepEqual(
weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'),
{ label: '☀', klass: 'sunny' },
'weather parses pill status JSON'
)
assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON')
assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures')
assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures')
assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures')
assertEqual(weather.formatTemp(22, false), '22°C', 'weather formats metric temperatures')
assertEqual(weather.dayName('2026-05-25'), 'Monday', 'weather derives day names')
const openMeteo = {
daily: {
time: ['2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29'],
temperature_2m_max: [20.1, 21.6, 18.2, 17.9, 22.4],
temperature_2m_min: [12.2, 13.1, 10.8, 9.2, 11.5],
weather_code: [0, 63, 95, 3, 1]
}
}
assertDeepEqual(
weather.openMeteoForecastDays(openMeteo, '2026-05-25').map(day => ({
date: day.date,
maxtempC: day.maxtempC,
mintempF: day.mintempF,
code: day.openMeteoWeatherCode
})),
[
{ date: '2026-05-26', maxtempC: '22', mintempF: '56', code: 63 },
{ date: '2026-05-27', maxtempC: '18', mintempF: '51', code: 95 },
{ date: '2026-05-28', maxtempC: '18', mintempF: '49', code: 3 }
],
'weather builds future Open-Meteo forecast days'
)
const wttr = {
weather: [
{ date: '2026-05-25', maxtempC: '20', mintempC: '12' },
{ date: '2026-05-26', maxtempC: '22', mintempC: '13' }
]
}
assertEqual(weather.buildForecastDays(wttr, {}, '2026-05-25')[0].date, '2026-05-26', 'weather falls back to wttr forecast')
assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'max', false), '22°', 'weather formats forecast metric highs')
assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'min', true), '55°', 'weather formats forecast imperial lows')
assert(weather.dayIcon({ openMeteoWeatherCode: 95 }).length > 0, 'weather maps Open-Meteo weather icons')
assertEqual(
weather.dayIcon({ hourly: [{ time: '900', weatherCode: 113 }, { time: '1200', weatherCode: 389 }, { time: '1800', weatherCode: 116 }] }),
weather.iconForCode(389, false),
'weather picks hourly forecast icon nearest noon'
)
JS