Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
455 lines
20 KiB
Bash
455 lines
20 KiB
Bash
#!/bin/bash
|
||
|
||
set -euo pipefail
|
||
|
||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||
|
||
run_node_test <<'JS'
|
||
const fs = require('fs')
|
||
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
|
||
const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8')
|
||
const defaultMenuJsonc = fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')
|
||
|
||
const parsed = menu.parseMenuJsonc(`
|
||
{
|
||
// comment
|
||
"items": {
|
||
"root": { "label": "Go" },
|
||
"style": { "label": "Style" },
|
||
"style.theme": {
|
||
"label": "Themes",
|
||
"aliases": "theme",
|
||
"description": "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: '',
|
||
iconFont: '',
|
||
label: 'Themes',
|
||
title: '',
|
||
target: '',
|
||
description: 'appearance colors',
|
||
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 visibilityItems = {
|
||
hardware: menu.normalizeItem('hardware', { label: 'Hardware' }),
|
||
laptop: menu.normalizeItem('hardware.laptop', { label: 'Laptop', when: 'is-laptop', action: 'toggle-laptop' }),
|
||
nested: menu.normalizeItem('nested', { label: 'Nested' }),
|
||
branch: menu.normalizeItem('nested.branch', { label: 'Branch' }),
|
||
leaf: menu.normalizeItem('nested.branch.leaf', { label: 'Leaf', when: 'has-leaf', action: 'run-leaf' }),
|
||
dynamic: menu.normalizeItem('dynamic', { label: 'Dynamic', provider: 'items' })
|
||
}
|
||
const visibilityOrder = Object.keys(visibilityItems)
|
||
assert(!menu.isVisible(visibilityItems, visibilityOrder, { 'hardware.laptop': false }, visibilityItems.hardware), 'menu hides a submenu with no visible children')
|
||
assert(menu.isVisible(visibilityItems, visibilityOrder, { 'hardware.laptop': true }, visibilityItems.hardware), 'menu shows a submenu with a visible child')
|
||
assert(!menu.isVisible(visibilityItems, visibilityOrder, { 'nested.branch.leaf': false }, visibilityItems.nested), 'menu hides recursively empty submenus')
|
||
assert(menu.isVisible(visibilityItems, visibilityOrder, {}, visibilityItems.dynamic), 'menu keeps provider-backed submenus visible')
|
||
|
||
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 description matches')
|
||
|
||
assertDeepEqual(
|
||
menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'),
|
||
{
|
||
itemId: 'style.theme',
|
||
kind: 'action',
|
||
icon: '',
|
||
iconFont: '',
|
||
appIcon: '',
|
||
appId: '',
|
||
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'
|
||
)
|
||
|
||
const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc)
|
||
const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item]))
|
||
|
||
// Needs the real menu: app rows sort after all menu items, and only at that
|
||
// item count does the order tiebreak alone bury an installed app.
|
||
const rankBase = menu.mergeMenuSources(defaultItems, [])
|
||
const ranked = menu.mergeAppRows(rankBase.items, rankBase.itemOrder, [
|
||
{ id: 'apps.brave', parent: 'apps', kind: 'app', label: 'Brave', description: '', aliases: [] },
|
||
{ id: 'apps.fontforge', parent: 'apps', kind: 'app', label: 'FontForge', description: '', aliases: [] },
|
||
{ id: 'apps.zen', parent: 'apps', kind: 'app', label: 'Zen Browser', description: '', aliases: [] }
|
||
])
|
||
const rankScore = (id, query) => menu.searchScore(ranked.items, ranked.items[id], query)
|
||
assert(
|
||
['install.browser.brave', 'remove.browser.brave', 'setup.default.browser.brave'].every(
|
||
id => rankScore('apps.brave', 'brave') < rankScore(id, 'brave')
|
||
),
|
||
'menu ranks an installed app above menu entries matching the query equally well'
|
||
)
|
||
assert(
|
||
['install.browser.zen', 'remove.browser.zen', 'setup.default.browser.zen'].every(
|
||
id => rankScore('apps.zen', 'zen') < rankScore(id, 'zen')
|
||
),
|
||
'menu ranks an app matching the query as a whole word above exact-labeled menu entries'
|
||
)
|
||
assert(
|
||
rankScore('style.font', 'font') < rankScore('apps.fontforge', 'font'),
|
||
'menu keeps a better-matching menu entry above a weaker app match'
|
||
)
|
||
const triggerItems = defaultItems.filter(item => item.parent === 'trigger')
|
||
assertEqual(
|
||
triggerItems[0].id,
|
||
'trigger.emoji',
|
||
'menu lists Emoji first under Trigger'
|
||
)
|
||
assertEqual(
|
||
defaultById['trigger.emoji'].action,
|
||
'omarchy-menu-emoji',
|
||
'menu opens the emoji picker from Trigger'
|
||
)
|
||
assert(
|
||
defaultById['update.omarchy'].icon === '\ue900',
|
||
'menu update Omarchy entry uses the Omarchy glyph'
|
||
)
|
||
assert(
|
||
defaultById['update.omarchy'].iconFont === 'omarchy',
|
||
'menu update Omarchy entry renders the private glyph with the Omarchy font'
|
||
)
|
||
assert(
|
||
defaultById['setup.input'].action.includes('input.lua'),
|
||
'menu keeps Input as a direct config action'
|
||
)
|
||
assert(
|
||
defaultById['setup.direct-boot'].action.includes('omarchy-setup-direct-boot'),
|
||
'menu places Direct Boot directly under Setup'
|
||
)
|
||
assertEqual(
|
||
defaultItems.findIndex(item => item.id === 'setup.direct-boot'),
|
||
defaultItems.findIndex(item => item.id === 'setup.input') + 1,
|
||
'menu lists Direct Boot immediately below Input'
|
||
)
|
||
assert(
|
||
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
||
'menu places Passwordless Sudo under Setup > Security'
|
||
)
|
||
assert(
|
||
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
||
'menu removes the relocated toggles from Trigger > Toggle'
|
||
)
|
||
assert(
|
||
defaultById['style.bar.position'].kind === 'menu',
|
||
'menu groups Menu Bar positions in a submenu'
|
||
)
|
||
assert(
|
||
['top', 'bottom', 'left', 'right'].every(position => defaultById[`style.bar.position.${position}`].action === `omarchy-bar position ${position}`),
|
||
'menu lists all Menu Bar positions under Position'
|
||
)
|
||
assertEqual(
|
||
defaultById['style.bar.transparency'].action,
|
||
'omarchy-bar transparent toggle',
|
||
'menu exposes Menu Bar transparency as a toggle'
|
||
)
|
||
assertDeepEqual(
|
||
defaultItems.filter(item => item.parent === 'setup.plugin').map(item => item.label),
|
||
['Enable Plugin', 'Disable Plugin', 'Add Plugin', 'Clone Plugin', 'Remove Plugin'],
|
||
'menu manages plugins from Setup > Plugins'
|
||
)
|
||
assert(
|
||
['enable', 'disable', 'clone', 'remove'].every(
|
||
verb => defaultById[`setup.plugin.${verb}`].action === `omarchy-menu-plugin ${verb}`
|
||
),
|
||
'menu picks a plugin the way it already picks a theme or a timezone'
|
||
)
|
||
assert(
|
||
!defaultById['setup.plugin.enable'].when && !defaultById['setup.plugin.disable'].when,
|
||
'menu always offers Enable and Disable, which cover the built-in plugins too'
|
||
)
|
||
assert(
|
||
defaultById['setup.plugin.remove'].when.includes('.config/omarchy/plugins'),
|
||
'menu hides Remove until a plugin the user installed exists to delete'
|
||
)
|
||
assert(
|
||
defaultById['setup.plugin.add'].action.includes('omarchy-plugin-add'),
|
||
'menu adds a plugin through the CLI, where the trust warning and clone output are visible'
|
||
)
|
||
|
||
const pluginPicker = fs.readFileSync(path.join(root, 'bin/omarchy-menu-plugin'), 'utf8')
|
||
assert(
|
||
/enable\).*\(\.enabled \| not\)/.test(pluginPicker) && /disable\).*\.canDisable and \.enabled/.test(pluginPicker),
|
||
'plugin picker offers what each verb can act on'
|
||
)
|
||
assert(
|
||
/remove\).*\(\.firstParty \| not\)/.test(pluginPicker)
|
||
&& /clone\).*\.firstParty/.test(pluginPicker)
|
||
&& !/kinds|bar-widget|A_BAR_OPTION|NOT_A_BAR_OPTION|BAR_ICON/.test(pluginPicker),
|
||
'plugin picker leaves plugin-kind decisions to its data and the plugin command'
|
||
)
|
||
|
||
const pluginAdd = fs.readFileSync(path.join(root, 'bin/omarchy-plugin-add'), 'utf8')
|
||
const pluginEnable = fs.readFileSync(path.join(root, 'bin/omarchy-plugin-enable'), 'utf8')
|
||
assert(
|
||
/Now using \$id as the bar/.test(pluginEnable)
|
||
&& /omarchy-plugin-enable "\$id" "\$\{ENABLE_PLACEMENT\[@\]\}"/.test(pluginAdd),
|
||
'plugin enable reports a bar as replacing the one in use, whether enabled or freshly added'
|
||
)
|
||
assert(
|
||
/\.barWidget\.defaultSection \/\/ "center"/.test(pluginAdd)
|
||
&& /gum choose[\s\S]*?--selected "\$default_section"/.test(pluginAdd),
|
||
'interactive plugin add selects the manifest placement or center fallback by default'
|
||
)
|
||
assert(
|
||
/"omarchy-plugin-\$1" "\$id"/.test(pluginPicker),
|
||
'plugin picker delegates enable and disable without interpreting plugin kinds'
|
||
)
|
||
// Icons ride along as "<glyph>\tlabel"; the menu shows the glyph and hands
|
||
// back the label, so nothing downstream has to strip one off. The id rides in
|
||
// a third field, cut off before the menu is ever shown it. What the picker
|
||
// then does with the row it gets back is checked in menu-plugin-test.sh.
|
||
assert(
|
||
/\$label \+ \\"\\\\t\\" \+ \.id/.test(pluginPicker)
|
||
&& /omarchy-menu-select "\$\{1\^\} plugin" < <\(cut -f1,2 <<<"\$rows"\)/.test(pluginPicker),
|
||
'plugin picker labels its rows with glyphs and keeps the id out of the label'
|
||
)
|
||
assert(
|
||
/var icon = parts\.length > 1 \? parts\.shift\(\) : ""\s*\n\s*var label = parts\.join\("\\t"\)/.test(menuQml),
|
||
'menu select mode reads a leading icon off an option and filters on the label alone'
|
||
)
|
||
assert(
|
||
/omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove/.test(pluginPicker),
|
||
'plugin picker removes where the confirmation and backup path are visible'
|
||
)
|
||
|
||
// A font installed since the shell started should show up without a restart.
|
||
const providerBlock = menuQml.match(/readonly property var providers: \(\{[\s\S]*?\n \}\)/)[0]
|
||
assert(
|
||
/"fonts": \{[\s\S]*?volatile: true/.test(providerBlock),
|
||
'menu re-enumerates the font list every time it is opened'
|
||
)
|
||
assert(
|
||
/function setActiveMenu\([\s\S]*?root\.invalidateVolatileProvider\(id\)\s*\n\s*root\.loadProviderForMenu\(id\)/.test(menuQml)
|
||
&& /function openExistingMenu\([\s\S]*?invalidateVolatileProvider\(activeMenu\)\s*\n\s*loadProviderForMenu\(activeMenu\)/.test(menuQml),
|
||
'menu invalidates volatile providers when entering a menu, not on every keystroke'
|
||
)
|
||
assert(
|
||
['loadProviderForMenu', 'loadProvidersForSearch'].every(
|
||
name => !menuQml.match(new RegExp(`function ${name}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`))[1].includes('invalidateVolatileProvider')
|
||
),
|
||
'menu search never restarts a volatile provider'
|
||
)
|
||
assertEqual(
|
||
defaultById['trigger.hardware.laptop-display'].when,
|
||
'omarchy-hw-laptop',
|
||
'menu only shows Laptop Display on laptops'
|
||
)
|
||
assertEqual(
|
||
defaultById['trigger.hardware.mirror-display'].when,
|
||
'omarchy-hw-laptop',
|
||
'menu only shows Mirror Display on laptops'
|
||
)
|
||
assertEqual(
|
||
defaultById['trigger.capture.screenrecord.webcam'].when,
|
||
'omarchy-hw-webcam',
|
||
'menu only shows webcam screen recording when a webcam is available'
|
||
)
|
||
assert(
|
||
/font\.family: row\.iconFont\.length > 0 \? row\.iconFont : root\.fontFamily/.test(menuQml),
|
||
'menu rows support per-icon font families'
|
||
)
|
||
|
||
assert(
|
||
/function select\(delta\)[\s\S]*root\.disarmPointer\(\)[\s\S]*selectedIndex =/.test(menuQml),
|
||
'menu keyboard navigation disarms pointer selection'
|
||
)
|
||
assert(
|
||
/function setFilter\(nextFilter\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml),
|
||
'menu filter changes disarm pointer selection'
|
||
)
|
||
assert(
|
||
/function setActiveMenu\(id, pushHistory, fromPointer\)[\s\S]*if \(fromPointer\) pointerGate\.allowInitialSample\(\)\s*else root\.disarmPointer\(\)/.test(menuQml),
|
||
'menu route changes only accept an initial pointer sample for mouse activation'
|
||
)
|
||
assert(
|
||
/\(event\.key === Qt\.Key_Backspace \|\| event\.key === Qt\.Key_Left\) && !root\.filterText[\s\S]*root\.goBack\(\)/.test(menuQml),
|
||
'menu Left key follows empty-filter Backspace navigation'
|
||
)
|
||
assert(
|
||
/PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(menuQml),
|
||
'menu uses shared pointer movement gate in card coordinates'
|
||
)
|
||
assert(
|
||
/function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml),
|
||
'menu resets pointer movement gate when pointer selection is disarmed'
|
||
)
|
||
// App rows are rebuilt from scratch on every desktop-entry rescan. The merge
|
||
// must be idempotent and must never carry an orphan id forward, or a single
|
||
// lost write turns into an app listed twice (and thrice, and so on).
|
||
const nonAppItems = {
|
||
root: { id: 'root', kind: 'menu', label: 'Go' },
|
||
apps: { id: 'apps', kind: 'menu', label: 'Apps', provider: 'apps' }
|
||
}
|
||
const nonAppOrder = ['root', 'apps']
|
||
const appRowsFor = ids => ids.map(id => ({ id: `apps.${id}`, kind: 'app', parent: 'apps', label: id, appId: id }))
|
||
|
||
const firstMerge = menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['alacritty', 'youtube']))
|
||
assert(
|
||
firstMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube',
|
||
'app merge appends app rows after the static menu items'
|
||
)
|
||
|
||
const secondMerge = menu.mergeAppRows(firstMerge.items, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube']))
|
||
assert(
|
||
secondMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube',
|
||
'repeating the app merge with the same entries does not duplicate rows'
|
||
)
|
||
|
||
assert(
|
||
menu.mergeAppRows(secondMerge.items, secondMerge.itemOrder, appRowsFor(['alacritty'])).itemOrder.join(',')
|
||
=== 'root,apps,apps.alacritty',
|
||
'app merge drops rows for entries that went away'
|
||
)
|
||
|
||
assert(
|
||
menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['youtube', 'youtube'])).itemOrder.join(',')
|
||
=== 'root,apps,apps.youtube',
|
||
'app merge lists an app once even when two desktop entries share an id'
|
||
)
|
||
|
||
const orphanedItems = {}
|
||
for (const key in firstMerge.items) orphanedItems[key] = firstMerge.items[key]
|
||
delete orphanedItems['apps.youtube']
|
||
const healed = menu.mergeAppRows(orphanedItems, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube']))
|
||
assert(
|
||
healed.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube'
|
||
&& !!healed.items['apps.youtube'],
|
||
'app merge heals an order entry whose item went missing instead of duplicating it'
|
||
)
|
||
|
||
assert(
|
||
!firstMerge.items['apps.youtube'].hasOwnProperty('__probe')
|
||
&& (() => {
|
||
const before = Object.keys(nonAppItems).length
|
||
menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['gimp']))
|
||
return Object.keys(nonAppItems).length === before
|
||
})(),
|
||
'app merge leaves the map it was handed untouched'
|
||
)
|
||
|
||
const providerRowsFor = values => values.map(value => ({ id: `style.font.${value}`, kind: 'action', parent: 'style.font', label: value }))
|
||
const firstProviderMerge = menu.swapProviderRows(nonAppItems, nonAppOrder, 'style.font', providerRowsFor(['mono', 'serif']))
|
||
assert(
|
||
firstProviderMerge.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
|
||
'provider merge appends its rows'
|
||
)
|
||
assert(
|
||
menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.font', providerRowsFor(['mono', 'serif']))
|
||
.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
|
||
'repeating a provider merge does not duplicate rows'
|
||
)
|
||
// A plugin drops out of the Enable list the moment it is enabled, so a
|
||
// provider that runs again has to lose the rows it contributed last time.
|
||
const rerunProviderMerge = menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.font', providerRowsFor(['serif']))
|
||
assert(
|
||
rerunProviderMerge.itemOrder.join(',') === 'root,apps,style.font.serif',
|
||
'provider merge drops rows the provider no longer lists'
|
||
)
|
||
assert(
|
||
menu.swapProviderRows(firstProviderMerge.items, firstProviderMerge.itemOrder, 'style.other', providerRowsFor([]))
|
||
.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
|
||
'provider merge leaves rows belonging to another provider alone'
|
||
)
|
||
// Rows are keyed by id, so a provider handing over two rows with the same id
|
||
// would lose one. Distinct plugin ids can slugify alike, which is why the
|
||
// menu makes each row id its own before merging.
|
||
assertEqual(
|
||
['acme.foo', 'acme_foo', 'acme-foo'].map(menu.slugify).join(','),
|
||
'acme-foo,acme-foo,acme-foo',
|
||
'menu slugs collide across plugin ids that differ only in separator'
|
||
)
|
||
assert(
|
||
/var rowId = menuId \+ "\." \+ root\.slugify\(value\)\s*\n\s*while \(takenIds\[rowId\]\) rowId \+= "-"/.test(menuQml),
|
||
'menu keeps colliding provider rows apart so none is dropped'
|
||
)
|
||
|
||
// The maps live in QML `var` properties, where an in-place write is
|
||
// occasionally dropped by the engine, so both merges must hand back fresh
|
||
// objects for the caller to assign in one shot.
|
||
assert(
|
||
/var merged = MenuModel\.mergeAppRows\(root\.items, root\.itemOrder, appRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml),
|
||
'menu assigns the rebuilt app item map instead of mutating it in place'
|
||
)
|
||
assert(
|
||
/var merged = MenuModel\.swapProviderRows\(root\.items, root\.itemOrder, menuId, providerRows\)\s*\n[\s\S]*?root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml),
|
||
'menu assigns the rebuilt provider item map instead of mutating it in place'
|
||
)
|
||
assert(
|
||
!/root\.items\[[^\]]+\] =/.test(menuQml) && !/delete root\.items\[/.test(menuQml),
|
||
'menu never writes into the item map held by the var property'
|
||
)
|
||
|
||
for (const functionName of ['openExistingMenu', 'openDmenu']) {
|
||
const openMatch = menuQml.match(new RegExp(`function ${functionName}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`))
|
||
assert(openMatch, `menu ${functionName} function exists`)
|
||
assert(
|
||
openMatch[1].indexOf('root.disarmPointer()') < openMatch[1].indexOf('opened = true')
|
||
&& !openMatch[1].includes('pointerGate.allowInitialSample()'),
|
||
`menu ${functionName} ignores a stale hidden-pointer position when becoming visible`
|
||
)
|
||
}
|
||
assert(
|
||
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(menuQml),
|
||
'menu only selects from pointer after real movement'
|
||
)
|
||
assert(
|
||
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(menuQml),
|
||
'menu row hover routes through pointer movement gate'
|
||
)
|
||
assert(
|
||
/onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(menuQml),
|
||
'menu samples pointer movement immediately when entering a row'
|
||
)
|
||
assert(
|
||
/function activateIndex\(index, fromPointer\)[\s\S]*root\.setActiveMenu\(row\.target \|\| row\.itemId, true, fromPointer\)/.test(menuQml)
|
||
&& /onClicked:[\s\S]*root\.activateIndex\(row\.index, true\)/.test(menuQml),
|
||
'mouse activation carries pointer intent into subordinate menus'
|
||
)
|
||
JS
|