* Delete extra themes from the theme picker Delete on an extra (user-installed) theme opens the shared faded confirmation dialog; confirming runs omarchy-theme-remove and selection lands on the theme before the deleted one. Stock themes and user overrides of them are not offered for deletion, apply/re-ask are blocked while a removal runs, and row refreshes queue so a deletion never leaves the list stale. * Fail closed on theme deletion until stock themes are known Keep Delete disabled until the stock listing finishes successfully, so a stock override cannot look deletable in the startup window. Also hold theme apply (and further delete asks) from removal exit until the refreshed rows load, so Enter cannot apply the just-deleted theme in the gap.
488 lines
28 KiB
Bash
488 lines
28 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 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')
|
|
|
|
class WindowModel {
|
|
constructor() { this.items = []; this.insertions = 0 }
|
|
get count() { return this.items.length }
|
|
get(i) { return this.items[i] }
|
|
remove(i) { this.items.splice(i, 1) }
|
|
insert(i, item) { this.items.splice(i, 0, { ...item }); this.insertions++ }
|
|
move(from, to) { this.items.splice(to, 0, this.items.splice(from, 1)[0]) }
|
|
setProperty(i, key, value) { this.items[i][key] = value }
|
|
}
|
|
|
|
for (const count of [0, 1, 200, 530, 10000]) {
|
|
const collection = Array.from({ length: count }, (_, i) => ({ filePath: `/themes/theme-${i}.png` }))
|
|
const indices = picker.matchingIndices(collection, '')
|
|
const model = new WindowModel()
|
|
for (const selected of [0, Math.min(1, count - 1), Math.floor(count / 2), count - 1]) {
|
|
const window = picker.visibleWindow(indices, selected, 16)
|
|
picker.syncWindow(model, window)
|
|
assertDeepEqual(model.items, window, `carousel window reconciles ${count} images at ${selected}`)
|
|
assert(model.count <= 33, `carousel bounds delegates for ${count} images`)
|
|
if (count) assert(window.some(item => item.imageIndex === selected && item.relativeIndex === 0), `carousel includes selection in ${count} images`)
|
|
}
|
|
const matches = picker.matchingIndices(collection, 'theme-19')
|
|
picker.syncWindow(model, picker.visibleWindow(matches, matches[0], 16))
|
|
assert(model.items.every(item => collection[item.imageIndex].filePath.includes('theme-19')), `carousel filters ${count} images`)
|
|
picker.syncWindow(model, picker.visibleWindow([], 0, 16))
|
|
assertEqual(model.count, 0, `carousel clears ${count} images when there are no matches`)
|
|
}
|
|
|
|
const windowModel = new WindowModel()
|
|
const allIndices = Array.from({ length: 200 }, (_, i) => i)
|
|
picker.syncWindow(windowModel, picker.visibleWindow(allIndices, 100, 16))
|
|
const retained = windowModel.get(17)
|
|
picker.syncWindow(windowModel, picker.visibleWindow(allIndices, 101, 16))
|
|
assertEqual(windowModel.get(16), retained, 'carousel retains overlapping delegates when navigating')
|
|
assertEqual(windowModel.insertions, 34, 'carousel creates only one new delegate for an adjacent selection')
|
|
for (const radius of [1, 8, 16]) {
|
|
assertEqual(picker.visibleWindow(allIndices, 100, radius).length, radius * 2 + 1, `carousel scales its window to radius ${radius}`)
|
|
}
|
|
|
|
const imagePickerQml = fs.readFileSync(path.join(root, 'shell/plugins/image-picker/ImagePicker.qml'), 'utf8')
|
|
// Exercise the actual QML refresh handler: model-only filtering tests cannot
|
|
// catch a row refresh selecting an image outside the active filter.
|
|
const refreshHandler = imagePickerQml.match(/function loadRows\(rows, reveal\) \{[\s\S]*?\n \}/)[0]
|
|
const refreshRoot = {
|
|
filterText: 'dark',
|
|
selectedImage: '/themes/removed-dark.png',
|
|
requestSerial: 1,
|
|
indexForSelectedImage(images) { return picker.indexForSelectedImage(images, this.selectedImage) },
|
|
enableNeighborsWhenReady() {},
|
|
revealWhenSettled() {}
|
|
}
|
|
const refreshContext = {
|
|
root: refreshRoot,
|
|
ImagePickerModel: picker,
|
|
Qt: { callLater(callback) { callback() } }
|
|
}
|
|
require('vm').runInNewContext(`${refreshHandler}; loadRows`, refreshContext)
|
|
const refreshRows = '/themes/light.png\n/themes/remaining-dark.png\n/themes/other-dark.png'
|
|
refreshContext.loadRows(refreshRows, false)
|
|
assertEqual(refreshRoot.selectedIndex, 1, 'filtered refresh selects a visible row after the selected theme is removed')
|
|
assert(picker.visibleWindow(picker.matchingIndices(refreshRoot.imageArray, 'dark'), refreshRoot.selectedIndex, 8)
|
|
.some(item => item.imageIndex === refreshRoot.selectedIndex), 'filtered refresh has a selected delegate to start preview loading')
|
|
refreshRoot.selectedImage = '/themes/other-dark.png'
|
|
refreshContext.loadRows(refreshRows, false)
|
|
assertEqual(refreshRoot.selectedIndex, 2, 'filtered refresh preserves a selected theme that still matches')
|
|
refreshRoot.selectedImage = '/themes/light.png'
|
|
refreshContext.loadRows(refreshRows, false)
|
|
assertEqual(refreshRoot.selectedIndex, 1, 'filtered refresh moves a hidden selection to the first match')
|
|
refreshRoot.filterText = 'missing'
|
|
refreshContext.loadRows(refreshRows, false)
|
|
assertEqual(refreshRoot.selectedIndex, -1, 'filtered refresh leaves no selection when nothing matches')
|
|
refreshRoot.filterText = ''
|
|
refreshContext.loadRows(refreshRows, false)
|
|
assertEqual(refreshRoot.selectedIndex, 0, 'unfiltered refresh retains its first-row fallback')
|
|
refreshContext.loadRows('', false)
|
|
assertEqual(refreshRoot.selectedIndex, -1, 'empty refresh has no selected image')
|
|
|
|
assert(
|
|
/function preloadRows[\s\S]*if \(opened \|\| requestActive\) return/.test(imagePickerQml),
|
|
'image picker ignores cache preloads while a request is visible'
|
|
)
|
|
assert(
|
|
/if \(args\.source === "themes"\) \{\s*openThemes\(\)/.test(imagePickerQml) &&
|
|
/function openThemes\(\) \{\s*if \(themeRows\) \{\s*openThemeRows\(\)[\s\S]*refreshThemeRows\(\)/.test(imagePickerQml),
|
|
'image picker opens themes from held rows before refreshing them'
|
|
)
|
|
assert(
|
|
/command: \[root\.omarchyPath \+ "\/bin\/omarchy-theme-switcher", "--print-rows"\]/.test(imagePickerQml),
|
|
'image picker refreshes theme rows from the theme switcher'
|
|
)
|
|
assert(
|
|
/if \(themeMode\) \{[\s\S]*Util\.execArgv\(\["omarchy-theme-set", nameForPath\(path\)\]\)/.test(imagePickerQml),
|
|
'image picker applies a chosen theme itself'
|
|
)
|
|
assert(
|
|
/function cancel\(\) \{\s*themeOpenPending = false/.test(imagePickerQml) &&
|
|
/function closeSelector\(nextDoneFile\) \{\s*requestSerial \+= 1\s*themeOpenPending = false/.test(imagePickerQml),
|
|
'image picker drops a pending theme open once dismissed'
|
|
)
|
|
assert(
|
|
/function openSelector[\s\S]*?themeMode = false/.test(imagePickerQml),
|
|
'image picker leaves theme mode when another caller opens it'
|
|
)
|
|
assert(
|
|
/OverlayWindow \{\s*id: panel\s*shown: root\.opened\s*shownKeyboardFocus: root\.imagesLoaded \? WlrKeyboardFocus\.Exclusive : WlrKeyboardFocus\.None/.test(imagePickerQml),
|
|
'image picker uses OverlayWindow and takes the keyboard once images load'
|
|
)
|
|
assert(
|
|
/model: visibleImages/.test(imagePickerQml) &&
|
|
/asynchronous: true\s*cache: false/.test(imagePickerQml),
|
|
'image picker renders its window with bounded asynchronous decoding'
|
|
)
|
|
const sourceWidth = imagePickerQml.match(/sourceSize\.width: ([^\n]+)/)[1]
|
|
const sourceHeight = imagePickerQml.match(/sourceSize\.height: ([^\n]+)/)[1]
|
|
const decodeSize = new Function('root', 'Screen', `return [${sourceWidth}, ${sourceHeight}]`)
|
|
for (const [scale, expected] of [
|
|
[1, [768, 475]],
|
|
[1.25, [960, 594]],
|
|
[1.5, [1152, 713]],
|
|
[2, [1536, 950]]
|
|
]) {
|
|
assertDeepEqual(
|
|
decodeSize({ expandedWidth: 768, expandedHeight: 475 }, { devicePixelRatio: scale }),
|
|
expected,
|
|
`image picker decodes enough physical pixels at ${scale}x display scale`
|
|
)
|
|
}
|
|
assert(
|
|
imagePickerQml.includes('(item.selected || root.neighborImagesEnabled)') &&
|
|
/onStatusChanged: if \(item.selected && \(status === Image.Ready \|\| status === Image.Error\)\) root.neighborImagesEnabled = true/.test(imagePickerQml),
|
|
'image picker prioritizes the selected preview and releases neighbors on success or failure'
|
|
)
|
|
assert(
|
|
!/Behavior on (opacity|x|y|width|height)/.test(imagePickerQml) && !imagePickerQml.includes('NumberAnimation'),
|
|
'image picker appears instantly with no fade or geometry transitions'
|
|
)
|
|
assertEqual((imagePickerQml.match(/layoutSettled = true/g) || []).length, 1, 'image picker settles its layout in exactly one place')
|
|
assert(
|
|
/readonly property bool previewSettled: !thumbnailPath \|\| previewReady/.test(imagePickerQml) &&
|
|
/onPreviewSettledChanged: if \(previewSettled\) root\.maybeReveal\(\)/.test(imagePickerQml) &&
|
|
/function allPreviewsSettled\(\) \{[\s\S]*?for \(var i = 0; i < imageCards\.count; i\+\+\) \{[\s\S]*?if \(!item \|\| !item\.previewSettled\) return false[\s\S]*?return true/.test(imagePickerQml) &&
|
|
/function maybeReveal\(\) \{\s*if \(!allPreviewsSettled\(\) \|\| renderedFrames < 2\) return[\s\S]*?Qt\.callLater\(function\(\) \{\s*if \(root\.allPreviewsSettled\(\) && root\.renderedFrames >= 2\) root\.settleReveal\(\)/.test(imagePickerQml) &&
|
|
/function settleReveal\(\) \{\s*if \(!opened \|\| !imagesLoaded \|\| layoutSettled \|\| imageArray\.length === 0\) return\s*layoutSettled = true\s*focusPicker\(\)/.test(imagePickerQml),
|
|
'image picker reveals only once every visible preview has loaded'
|
|
)
|
|
assert(
|
|
/id: card\s*visible: root\.opened && root\.imagesLoaded && root\.imageArray\.length > 0\s*opacity: root\.layoutSettled \? 1 : 0/.test(imagePickerQml) &&
|
|
/FrameAnimation \{\s*running: root\.opened && !root\.layoutSettled && root\.renderedFrames < 2\s*onTriggered: \{\s*root\.renderedFrames \+= 1\s*root\.maybeReveal\(\)/.test(imagePickerQml) &&
|
|
/if \(!allPreviewsSettled\(\) \|\| renderedFrames < 2\) return/.test(imagePickerQml) &&
|
|
/MouseArea \{ anchors\.fill: parent; enabled: root\.layoutSettled; onClicked: \{\} \}/.test(imagePickerQml),
|
|
'image picker pre-renders hidden for two frames so its first visible frame is complete'
|
|
)
|
|
assert(
|
|
!imagePickerQml.includes('color: root.scrim') && !/property color scrim:/.test(imagePickerQml),
|
|
'image picker shows no fullscreen scrim wash behind the carousel'
|
|
)
|
|
const colorQml = fs.readFileSync(path.join(root, 'shell/Commons/Color.qml'), 'utf8')
|
|
const shellTpl = fs.readFileSync(path.join(root, 'default/themed/shell.toml.tpl'), 'utf8')
|
|
const pickerSection = shellTpl.slice(shellTpl.indexOf('[image-picker]'), shellTpl.indexOf('\n[', shellTpl.indexOf('[image-picker]') + 1))
|
|
assert(
|
|
!colorQml.includes('image-picker.scrim') && !pickerSection.includes('scrim'),
|
|
'image picker scrim theme tokens are gone with the backdrop'
|
|
)
|
|
assert(
|
|
/Timer \{\s*interval: 400\s*running: root\.opened && root\.imagesLoaded && !root\.layoutSettled && root\.imageArray\.length > 0\s*onTriggered: root\.settleReveal\(\)/.test(imagePickerQml),
|
|
'image picker reveals anyway after a short wait if a preview stalls'
|
|
)
|
|
assert(
|
|
/if \(!root\.layoutSettled\) \{\s*if \(event\.key === Qt\.Key_Escape\) \{\s*root\.cancel\(\)\s*event\.accepted = true\s*\}\s*return\s*\}/.test(imagePickerQml) &&
|
|
imagePickerQml.indexOf('if (!root.layoutSettled) {') < imagePickerQml.indexOf('if (root.filterText) {'),
|
|
'image picker only accepts Escape before it is visible'
|
|
)
|
|
|
|
// Run the QML reveal guards themselves, the way the loadRows handler above
|
|
// runs, against mocked delegates in each state the gate must decide.
|
|
const revealFns = [
|
|
imagePickerQml.match(/function allPreviewsSettled\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function settleReveal\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function maybeReveal\(\) \{[\s\S]*?\n \}/)[0]
|
|
].join('\n')
|
|
function revealContext(settledFlags, frames) {
|
|
const ctx = {
|
|
opened: true,
|
|
imagesLoaded: true,
|
|
layoutSettled: false,
|
|
renderedFrames: frames,
|
|
imageArray: settledFlags.map(() => ({})),
|
|
focused: 0,
|
|
Qt: { callLater(callback) { callback() } }
|
|
}
|
|
const items = settledFlags.map(previewSettled => ({ previewSettled }))
|
|
ctx.imageCards = { count: items.length, itemAt(i) { return items[i] } }
|
|
ctx.focusPicker = () => { ctx.focused += 1 }
|
|
ctx.root = ctx
|
|
require('vm').runInNewContext(revealFns, ctx)
|
|
return ctx
|
|
}
|
|
let reveal = revealContext([true, true], 2)
|
|
reveal.maybeReveal()
|
|
assert(reveal.layoutSettled && reveal.focused === 1, 'reveal settles and focuses once every preview is settled and frames presented')
|
|
reveal = revealContext([true, false], 2)
|
|
reveal.maybeReveal()
|
|
assert(!reveal.layoutSettled, 'an unsettled preview holds the reveal')
|
|
reveal = revealContext([true, true], 1)
|
|
reveal.maybeReveal()
|
|
assert(!reveal.layoutSettled, 'reveal waits for presented frames')
|
|
reveal = revealContext([], 2)
|
|
reveal.maybeReveal()
|
|
assert(!reveal.layoutSettled, 'reveal waits while no cards are rendered')
|
|
reveal = revealContext([false], 0)
|
|
reveal.settleReveal()
|
|
assert(reveal.layoutSettled, 'fallback settle reveals despite a stalled preview')
|
|
reveal = revealContext([true], 2)
|
|
reveal.opened = false
|
|
reveal.settleReveal()
|
|
assert(!reveal.layoutSettled, 'settle does nothing once the picker is closed')
|
|
|
|
assertEqual(typeof picker.parseThemeNames, 'function', 'image picker model parses extra theme listings')
|
|
assertDeepEqual(picker.parseThemeNames('fjord\ngiants\n\nspace\n'), ['fjord', 'giants', 'space'], 'image picker parses extra theme names one per line')
|
|
assertDeepEqual(picker.parseThemeNames(''), [], 'image picker parses an empty extra theme listing')
|
|
assert(picker.canDeleteTheme('fjord', ['fjord', 'giants']), 'an extra theme can be deleted from the picker')
|
|
assert(!picker.canDeleteTheme('nord', ['fjord', 'giants']), 'a stock theme cannot be deleted from the picker')
|
|
assert(!picker.canDeleteTheme('', ['fjord']), 'no selection cannot be deleted from the picker')
|
|
assert(!picker.canDeleteTheme('fjord', []), 'nothing can be deleted before extra themes are known')
|
|
assert(!picker.canDeleteTheme('nord', ['nord', 'fjord'], ['nord']), 'a user override of a stock theme is not deleted from the picker')
|
|
assert(picker.canDeleteTheme('fjord', ['fjord'], ['nord']), 'an extra theme beside the stock list can be deleted from the picker')
|
|
assert(
|
|
/command: \["find", root\.userThemesPath, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "!", "-xtype", "l", "-printf", "%f\\n"\]/.test(imagePickerQml) &&
|
|
/root\.extraThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) &&
|
|
/function openThemes\(\) \{[\s\S]*?refreshExtraThemes\(\)/.test(imagePickerQml),
|
|
'image picker learns extra themes the way theme remove lists them'
|
|
)
|
|
assert(
|
|
/ConfirmDialog \{\s*id: deleteConfirm\s*anchors\.fill: parent\s*opened: root\.deleteConfirmOpen\s*z: 10\s*message: "Do you want to delete " \+ \(root\.pendingDeleteTheme \? root\.labelForPath\(root\.pendingDeleteTheme\) : ""\) \+ "\?"\s*confirmText: "Delete"/.test(imagePickerQml) &&
|
|
/onCanceled: root\.cancelDeleteTheme\(\)\s*onConfirmed: root\.confirmDeleteTheme\(\)/.test(imagePickerQml),
|
|
'image picker confirms theme deletion in a faded centered dialog'
|
|
)
|
|
assert(
|
|
/if \(root\.deleteConfirmOpen\) \{\s*if \(deleteConfirm\.handleKey\(event\)\) event\.accepted = true\s*return\s*\}/.test(imagePickerQml) &&
|
|
imagePickerQml.indexOf('if (root.deleteConfirmOpen) {') < imagePickerQml.indexOf('if (!root.layoutSettled) {') &&
|
|
/else if \(event\.key === Qt\.Key_Delete && !\(event\.modifiers & \(Qt\.ControlModifier \| Qt\.AltModifier \| Qt\.MetaModifier\)\)\) \{\s*root\.requestDeleteSelectedTheme\(\)\s*event\.accepted = true/.test(imagePickerQml),
|
|
'image picker routes keys to the delete confirmation first and maps Delete to it'
|
|
)
|
|
assert(
|
|
/onOpenedChanged: if \(!opened\) \{ layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false \}/.test(imagePickerQml) &&
|
|
/id: deleteThemeProc\s*onExited: function\(exitCode\) \{\s*if \(exitCode === 0\) root\.awaitingDeleteRefresh = true\s*else root\.deleteSelectionPath = ""\s*root\.refreshExtraThemes\(\)\s*root\.refreshThemeRows\(\)/.test(imagePickerQml),
|
|
'image picker drops a pending deletion on close and refreshes after one runs'
|
|
)
|
|
|
|
// Run the delete flow itself against a mocked selection and remove process.
|
|
const deleteFns = [
|
|
imagePickerQml.match(/function selectedThemeName\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function canDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function requestDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function cancelDeleteTheme\(\) \{[\s\S]*?\n \}/)[0],
|
|
imagePickerQml.match(/function confirmDeleteTheme\(\) \{[\s\S]*?\n \}/)[0]
|
|
].join('\n')
|
|
function deleteContext(path, extras, inThemeMode) {
|
|
const ctx = {
|
|
themeMode: inThemeMode,
|
|
extraThemeNames: extras,
|
|
stockThemeNames: [],
|
|
stockThemesKnown: true,
|
|
awaitingDeleteRefresh: false,
|
|
pendingDeleteTheme: '',
|
|
deleteSelectionPath: '',
|
|
deleteConfirmOpen: false,
|
|
imageArray: [{ filePath: path }],
|
|
selectedIndex: 0,
|
|
filterText: '',
|
|
deleteThemeProc: { running: false, command: [] },
|
|
deleteConfirm: { selectedIndex: 0 },
|
|
ImagePickerModel: picker,
|
|
nameForPath: picker.nameForPath,
|
|
currentPath() { return path }
|
|
}
|
|
ctx.root = ctx
|
|
require('vm').runInNewContext(deleteFns, ctx)
|
|
return ctx
|
|
}
|
|
let deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord', 'giants'], true)
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === 'fjord', 'Delete asks to confirm the selected extra theme')
|
|
deletion.cancelDeleteTheme()
|
|
assert(!deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === '' && deletion.deleteThemeProc.command.length === 0, 'canceling deletion removes nothing')
|
|
deletion.requestDeleteSelectedTheme()
|
|
deletion.confirmDeleteTheme()
|
|
assertDeepEqual(deletion.deleteThemeProc.command, ['omarchy-theme-remove', 'fjord'], 'confirming deletion removes exactly the selected theme')
|
|
assert(deletion.deleteThemeProc.running && !deletion.deleteConfirmOpen, 'confirming deletion runs the removal and closes the dialog')
|
|
deletion = deleteContext('/cache/previews/nord.png', ['fjord'], true)
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme')
|
|
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], false)
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does nothing in the background picker')
|
|
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
|
|
deletion.requestDeleteSelectedTheme()
|
|
deletion.deleteThemeProc.running = true
|
|
deletion.confirmDeleteTheme()
|
|
assertEqual(deletion.deleteThemeProc.command.length, 0, 'a second removal cannot start while one is running')
|
|
|
|
assertEqual(typeof picker.replacementSelectionPath, 'function', 'image picker model finds where to land after a deletion')
|
|
const siblings = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png'].map(filePath => ({ filePath }))
|
|
assertEqual(picker.replacementSelectionPath(siblings, 1, ''), '/p/alpha.png', 'deleting a theme lands on the one before it')
|
|
assertEqual(picker.replacementSelectionPath(siblings, 2, ''), '/p/bravo.png', 'deleting the last theme lands on the one before it')
|
|
assertEqual(picker.replacementSelectionPath(siblings, 0, ''), '/p/bravo.png', 'deleting the first theme lands on the new first theme')
|
|
assertEqual(picker.replacementSelectionPath(siblings.slice(0, 1), 0, ''), '', 'deleting the only theme has nowhere to land')
|
|
assertEqual(picker.replacementSelectionPath(siblings, 9, ''), '', 'a stale selection has nowhere to land')
|
|
const mixed = ['/p/dark-a.png', '/p/light-b.png', '/p/dark-c.png', '/p/dark-d.png'].map(filePath => ({ filePath }))
|
|
assertEqual(picker.replacementSelectionPath(mixed, 2, 'dark'), '/p/dark-a.png', 'deleting a filtered theme lands on the previous match')
|
|
assertEqual(picker.replacementSelectionPath(mixed, 0, 'dark'), '/p/dark-c.png', 'deleting the first match lands on the next match')
|
|
assert(
|
|
/function confirmDeleteTheme\(\) \{[\s\S]*?if \(name === selectedThemeName\(\)\) deleteSelectionPath = ImagePickerModel\.replacementSelectionPath\(imageArray, selectedIndex, filterText\)/.test(imagePickerQml) &&
|
|
/selectedImage = deleteSelectionPath \|\| currentPath\(\) \|\| currentThemePreview\(\)\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml),
|
|
'image picker retains its place by selecting the previous theme after a deletion'
|
|
)
|
|
|
|
function retentionContext(paths, selected, filter) {
|
|
const ctx = deleteContext(paths[selected], paths.map(filePath => picker.nameForPath(filePath)), true)
|
|
ctx.imageArray = paths.map(filePath => ({ filePath }))
|
|
ctx.selectedIndex = selected
|
|
ctx.filterText = filter
|
|
ctx.deleteSelectionPath = ''
|
|
return ctx
|
|
}
|
|
const trio = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png']
|
|
let retention = retentionContext(trio, 1, '')
|
|
retention.requestDeleteSelectedTheme()
|
|
retention.confirmDeleteTheme()
|
|
assertEqual(retention.deleteSelectionPath, '/p/alpha.png', 'confirming a deletion remembers the theme before it')
|
|
retention = retentionContext(trio, 0, '')
|
|
retention.requestDeleteSelectedTheme()
|
|
retention.confirmDeleteTheme()
|
|
assertEqual(retention.deleteSelectionPath, '/p/bravo.png', 'confirming the first theme remembers the one after it')
|
|
retention = retentionContext(trio, 1, '')
|
|
retention.requestDeleteSelectedTheme()
|
|
retention.currentPath = () => '/p/charlie.png'
|
|
retention.confirmDeleteTheme()
|
|
assertEqual(retention.deleteSelectionPath, '', 'no landing is remembered when the selection moved off the deleted theme')
|
|
assertDeepEqual(retention.deleteThemeProc.command, ['omarchy-theme-remove', 'bravo'], 'the confirmed theme is still the one removed')
|
|
|
|
assert(
|
|
/id: stockThemesProc\s*command: \["find", root\.omarchyPath \+ "\/themes", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"\]/.test(imagePickerQml) &&
|
|
/root\.stockThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) &&
|
|
/canDeleteTheme\(selectedThemeName\(\), extraThemeNames, stockThemeNames\)/.test(imagePickerQml),
|
|
'image picker knows stock theme names so overrides stay undeletable'
|
|
)
|
|
assert(
|
|
/function refreshThemeRows\(\) \{\s*if \(themeRowsProc\.running\) \{ themeRowsProc\.queued = true; return \}/.test(imagePickerQml) &&
|
|
/function refreshExtraThemes\(\) \{\s*if \(extraThemesProc\.running\) \{ extraThemesProc\.queued = true; return \}/.test(imagePickerQml) &&
|
|
(imagePickerQml.match(/property bool queued: false/g) || []).length === 2 &&
|
|
(imagePickerQml.match(/onExited: \{\s*if \(queued\) \{\s*queued = false\s*running = true\s*\}\s*\}/g) || []).length === 2,
|
|
'theme row and extra theme refreshes queue behind a running refresh'
|
|
)
|
|
assert(
|
|
/function applySelected\(\) \{\s*if \(themeMode && \(deleteThemeProc\.running \|\| awaitingDeleteRefresh\)\) return/.test(imagePickerQml) &&
|
|
/function canDeleteSelectedTheme\(\) \{\s*return themeMode && !deleteThemeProc\.running && !awaitingDeleteRefresh && stockThemesKnown && ImagePickerModel\.canDeleteTheme/.test(imagePickerQml) &&
|
|
/function requestDeleteSelectedTheme\(\) \{[\s\S]*?deleteConfirm\.selectedIndex = 1\s*deleteConfirmOpen = true/.test(imagePickerQml) &&
|
|
/function openSelector\([^)]*\) \{\s*deleteConfirmOpen = false\s*pendingDeleteTheme = ""\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml),
|
|
'image picker cannot apply or re-ask mid-removal and resets delete state per open'
|
|
)
|
|
|
|
deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true)
|
|
deletion.stockThemeNames = ['nord']
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme override')
|
|
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
|
|
deletion.stockThemeNames = ['nord']
|
|
deletion.deleteThemeProc.running = true
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does not ask while a removal is running')
|
|
|
|
const refreshFn = imagePickerQml.match(/function refreshThemeRows\(\) \{[\s\S]*?\n \}/)[0]
|
|
const refreshCtx = { themeRowsProc: { running: true, queued: false } }
|
|
require('vm').runInNewContext(refreshFn, refreshCtx)
|
|
refreshCtx.refreshThemeRows()
|
|
assert(refreshCtx.themeRowsProc.queued && refreshCtx.themeRowsProc.running, 'a refresh asked mid-refresh queues behind it')
|
|
refreshCtx.themeRowsProc.running = false
|
|
refreshCtx.themeRowsProc.queued = false
|
|
refreshCtx.refreshThemeRows()
|
|
assert(refreshCtx.themeRowsProc.running, 'a refresh asked while idle starts at once')
|
|
|
|
const updateFn = imagePickerQml.match(/function updateThemeRows\(rows\) \{[\s\S]*?\n \}/)[0]
|
|
const updateCtx = {
|
|
themeRows: 'old', themeOpenPending: false, themeMode: true, opened: true,
|
|
deleteSelectionPath: '/p/alpha.png', selectedImage: '', imageRows: '', loaded: null,
|
|
currentPath() { return '/p/bravo.png' },
|
|
currentThemePreview() { return '' },
|
|
openThemeRows() {},
|
|
loadRows(rows, reveal) { updateCtx.loaded = [rows, reveal] }
|
|
}
|
|
require('vm').runInNewContext(updateFn, updateCtx)
|
|
updateCtx.updateThemeRows('new-rows')
|
|
assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'a row refresh after deletion selects the remembered previous theme')
|
|
assertEqual(updateCtx.deleteSelectionPath, '', 'the remembered landing is spent by the refresh using it')
|
|
assertDeepEqual(updateCtx.loaded, ['new-rows', false], 'the refresh reloads rows without reopening the picker')
|
|
updateCtx.themeRows = 'new-rows'
|
|
updateCtx.loaded = null
|
|
updateCtx.updateThemeRows('new-rows')
|
|
assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'an unchanged refresh leaves the selection alone')
|
|
|
|
assert(
|
|
/property bool stockThemesKnown: false/.test(imagePickerQml) &&
|
|
/id: stockThemesProc[\s\S]*?onExited: function\(exitCode\) \{ if \(exitCode === 0\) root\.stockThemesKnown = true \}/.test(imagePickerQml),
|
|
'deletion stays disabled until the stock listing finishes successfully'
|
|
)
|
|
deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true)
|
|
deletion.stockThemeNames = []
|
|
deletion.stockThemesKnown = false
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does nothing before stock themes are known')
|
|
deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true)
|
|
deletion.stockThemeNames = ['nord']
|
|
deletion.awaitingDeleteRefresh = true
|
|
deletion.requestDeleteSelectedTheme()
|
|
assert(!deletion.deleteConfirmOpen, 'Delete does not ask while the post-delete refresh is pending')
|
|
|
|
const applyFn = imagePickerQml.match(/function applySelected\(\) \{[\s\S]*?\n \}/)[0]
|
|
function applyContext(awaiting) {
|
|
const ctx = {
|
|
themeMode: true, opened: true, awaitingDeleteRefresh: awaiting,
|
|
deleteThemeProc: { running: false },
|
|
currentPath() { return '/p/fjord.jpg' },
|
|
nameForPath: picker.nameForPath,
|
|
Util: { execArgv(argv) { ctx.applied = argv } }
|
|
}
|
|
ctx.root = ctx
|
|
require('vm').runInNewContext(applyFn, ctx)
|
|
return ctx
|
|
}
|
|
let application = applyContext(true)
|
|
application.applySelected()
|
|
assert(application.applied === undefined && application.opened, 'Enter cannot apply a deleted theme before rows reload')
|
|
application = applyContext(false)
|
|
application.applySelected()
|
|
assertDeepEqual(application.applied, ['omarchy-theme-set', 'fjord'], 'Enter applies the selected theme once no deletion is pending')
|
|
|
|
updateCtx.awaitingDeleteRefresh = true
|
|
updateCtx.deleteSelectionPath = '/p/alpha.png'
|
|
updateCtx.themeRows = 'new-rows'
|
|
updateCtx.updateThemeRows('after-delete-rows')
|
|
assertEqual(updateCtx.awaitingDeleteRefresh, false, 'the post-delete refresh loading rows releases the apply guard')
|
|
JS
|