feat: add breadcrumb settings search results

Replace category-only filtering with searchable setting-level results built from the rendered settings UI. Show localized breadcrumbs with safe token highlights, support German replacement spellings, index exceptional update and backup actions, and navigate to the correct module with scrolling, focus, and a temporary visual highlight.
This commit is contained in:
Sucukdeluxe
2026-08-24 20:45:22 +02:00
parent 9cb5c6913b
commit 49c112d2b1
6 changed files with 400 additions and 48 deletions
+187 -29
View File
@@ -4914,7 +4914,9 @@ function renderSettings() {
<span class="settings-nav-indicator" aria-hidden="true"></span> <span class="settings-nav-indicator" aria-hidden="true"></span>
${pageDefinitions.map((definition, index) => `<button class="settings-nav-button${index === 0 ? ' active' : ''}" data-settings-page="${definition.id}" data-search="${definition.label.toLowerCase()} ${definition.search}" aria-current="${index === 0 ? 'page' : 'false'}">${definition.label}</button>`).join('')} ${pageDefinitions.map((definition, index) => `<button class="settings-nav-button${index === 0 ? ' active' : ''}" data-settings-page="${definition.id}" data-search="${definition.label.toLowerCase()} ${definition.search}" aria-current="${index === 0 ? 'page' : 'false'}">${definition.label}</button>`).join('')}
</nav> </nav>
<div class="settings-search-results" id="settingsSearchResults" role="list" aria-label="Suchergebnisse" hidden></div>
<p class="settings-search-empty" id="settingsSearchEmpty" hidden>Keine passende Einstellung gefunden.</p> <p class="settings-search-empty" id="settingsSearchEmpty" hidden>Keine passende Einstellung gefunden.</p>
<span class="settings-search-status" id="settingsSearchStatus" role="status" aria-live="polite"></span>
<div class="settings-sidebar-status"> <div class="settings-sidebar-status">
<span class="view-sidebar-section-label">Speicherstatus</span> <span class="view-sidebar-section-label">Speicherstatus</span>
<div class="view-sidebar-summary view-sidebar-summary-block save-feedback" id="saveFeedback" role="status" aria-live="polite">Automatisch gespeichert</div> <div class="view-sidebar-summary view-sidebar-summary-block save-feedback" id="saveFeedback" role="status" aria-live="polite">Automatisch gespeichert</div>
@@ -4966,7 +4968,7 @@ function renderSettings() {
</div> </div>
</div> </div>
<div class="settings-section-label">Programmupdate</div> <div class="settings-section-label">Programmupdate</div>
<div class="settings-row program-update-row program-update-card"> <div class="settings-row program-update-row program-update-card" data-settings-search-entry data-settings-search-label="Nach Updates suchen">
<div class="program-update-copy"> <div class="program-update-copy">
<strong class="program-update-title">Nach neuer Version suchen</strong> <strong class="program-update-title">Nach neuer Version suchen</strong>
<span class="program-update-description">Verfügbare Updates werden zusammen mit dem Changelog angezeigt.</span> <span class="program-update-description">Verfügbare Updates werden zusammen mit dem Changelog angezeigt.</span>
@@ -5018,7 +5020,7 @@ function renderSettings() {
<input type="checkbox" class="settings-autosave" id="autoStartRestoredQueueInput" ${globalSettings.autoStartRestoredQueue ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="autoStartRestoredQueueInput" ${globalSettings.autoStartRestoredQueue ? 'checked' : ''}>
</div> </div>
<div class="settings-section-label">Quelldateien</div> <div class="settings-section-label">Quelldateien</div>
<div class="settings-option source-delete-option"> <div class="settings-option source-delete-option" data-settings-search-label="Nach erfolgreichem Upload löschen">
<div class="settings-option-copy"> <div class="settings-option-copy">
<label for="deleteSourceAfterSuccessfulUploadInput">Quelldatei nach vollständigem Upload dauerhaft löschen</label> <label for="deleteSourceAfterSuccessfulUploadInput">Quelldatei nach vollständigem Upload dauerhaft löschen</label>
<span class="settings-option-description">Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet.</span> <span class="settings-option-description">Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet.</span>
@@ -5239,14 +5241,14 @@ function renderSettings() {
<h3 id="onlineBackupHeading">Verschlüsseltes Online-Backup</h3> <h3 id="onlineBackupHeading">Verschlüsseltes Online-Backup</h3>
<p>Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.</p> <p>Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.</p>
</div> </div>
<div class="online-backup-key-row"> <div class="online-backup-key-row" data-settings-search-entry data-settings-search-section="Online-Backup">
<label for="onlineBackupKeyInput">Schlüssel importieren</label> <label for="onlineBackupKeyInput">Schlüssel importieren</label>
<input type="password" class="key-input" id="onlineBackupKeyInput" maxlength="75" pattern="MHU2-[A-Za-z0-9_-]{70}" spellcheck="false" autocomplete="off" placeholder="MHU2-…"> <input type="password" class="key-input" id="onlineBackupKeyInput" maxlength="75" pattern="MHU2-[A-Za-z0-9_-]{70}" spellcheck="false" autocomplete="off" placeholder="MHU2-…">
<button class="btn btn-secondary" id="restoreOnlineBackupBtn" disabled>Importieren</button> <button class="btn btn-secondary" id="restoreOnlineBackupBtn" disabled>Importieren</button>
</div> </div>
<p class="online-backup-warning">Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.</p> <p class="online-backup-warning">Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.</p>
<section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading"> <section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading" data-settings-search-entry data-settings-search-section="Online-Backup" data-settings-search-label="Online-Backups verwalten" data-settings-search-control="managedOnlineBackupHeading">
<h4 id="managedOnlineBackupHeading">Auf diesem Gerät erstellt</h4> <h4 id="managedOnlineBackupHeading" tabindex="-1">Auf diesem Gerät erstellt</h4>
<div class="online-backup-managed-list" id="managedOnlineBackupList"></div> <div class="online-backup-managed-list" id="managedOnlineBackupList"></div>
<div class="online-backup-refresh-status" id="managedOnlineBackupRefreshStatus" role="status" hidden> <div class="online-backup-refresh-status" id="managedOnlineBackupRefreshStatus" role="status" hidden>
<span id="managedOnlineBackupRefreshMessage"></span> <span id="managedOnlineBackupRefreshMessage"></span>
@@ -5254,7 +5256,7 @@ function renderSettings() {
</div> </div>
</section> </section>
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div> <div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
<footer class="online-backup-footer"> <footer class="online-backup-footer" data-settings-search-entry data-settings-search-section="Online-Backup" data-settings-search-label="Neuen Schlüssel erzeugen">
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button> <button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
</footer> </footer>
</section> </section>
@@ -5271,8 +5273,8 @@ function renderSettings() {
<input type="password" class="key-input" id="localBackupPasswordConfirmInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort wiederholen"> <input type="password" class="key-input" id="localBackupPasswordConfirmInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort wiederholen">
</div> </div>
<div class="backup-file-actions"> <div class="backup-file-actions">
<button class="btn btn-secondary" id="exportBackupBtn">Datei exportieren</button> <button class="btn btn-secondary" id="exportBackupBtn" data-settings-search-entry data-settings-search-section="Lokales Datei-Backup" data-settings-search-label="Datei exportieren">Datei exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Datei importieren</button> <button class="btn btn-secondary" id="importBackupBtn" data-settings-search-entry data-settings-search-section="Lokales Datei-Backup" data-settings-search-label="Datei importieren">Datei importieren</button>
</div> </div>
`; `;
@@ -5295,6 +5297,182 @@ function renderSettings() {
if (target === 'backup') loadManagedOnlineBackups(); if (target === 'backup') loadManagedOnlineBackups();
}; };
const normalizeSettingsSearchText = (value) => String(value || '')
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.toLocaleLowerCase(getUiLocale())
.replace(/ß/gu, 'ss')
.replace(/ae/gu, 'a')
.replace(/oe/gu, 'o')
.replace(/ue/gu, 'u');
const stableSettingsSectionLabel = (element) => {
const clone = element.cloneNode(true);
clone.querySelectorAll('.panel-status').forEach(status => status.remove());
return clone.textContent.trim();
};
const searchEntries = [];
pageDefinitions.forEach((definition) => {
const page = pages[definition.id];
let section = definition.label;
let entryIndex = 0;
page.querySelectorAll('.settings-section-label, .settings-row, .settings-option, [data-settings-search-entry]').forEach((element) => {
if (element.classList.contains('settings-section-label')) {
section = stableSettingsSectionLabel(element);
return;
}
const explicitEntry = element.hasAttribute('data-settings-search-entry');
if (!explicitEntry && element.closest('.settings-row, .settings-option') !== element) return;
const label = element.querySelector('label');
const sourceTitle = element.dataset.settingsSearchLabel || label?.textContent.trim() || element.textContent.trim();
if (!sourceTitle) return;
const sourceDescription = element.querySelector('.settings-option-description, .hint')?.textContent.trim() || '';
const sourceSection = element.dataset.settingsSearchSection || section;
const englishTitle = window.I18n.translateText(sourceTitle, 'en');
const englishDescription = window.I18n.translateText(sourceDescription, 'en');
const englishSection = window.I18n.translateText(sourceSection, 'en');
const englishPage = window.I18n.translateText(definition.label, 'en');
const targetControl = element.dataset.settingsSearchControl
? document.getElementById(element.dataset.settingsSearchControl)
: (label?.htmlFor
? document.getElementById(label.htmlFor)
: (element.matches('input, select, textarea, button') ? element : element.querySelector('input, select, textarea, button')));
if (!element.id) element.id = `settings-search-target-${definition.id}-${entryIndex++}`;
searchEntries.push({
pageId: definition.id,
pageLabel: definition.label,
section: sourceSection,
title: sourceTitle,
targetElement: element,
targetControl,
searchText: normalizeSettingsSearchText([
definition.label,
sourceSection,
sourceTitle,
sourceDescription,
englishPage,
englishSection,
englishTitle,
englishDescription
].join(' '))
});
});
});
const appendSettingsSearchHighlight = (container, text, tokens) => {
const source = String(text || '');
const normalizedTokens = tokens.map(normalizeSettingsSearchText).filter(Boolean);
if (normalizedTokens.length === 0) {
container.append(source);
return;
}
let normalizedSource = '';
const sourceRanges = [];
for (let offset = 0; offset < source.length;) {
const character = String.fromCodePoint(source.codePointAt(offset));
const end = offset + character.length;
const normalizedCharacter = normalizeSettingsSearchText(character);
normalizedSource += normalizedCharacter;
for (let index = 0; index < normalizedCharacter.length; index++) sourceRanges.push({ start: offset, end });
offset = end;
}
const matches = [];
for (const token of normalizedTokens) {
let start = 0;
while (start < normalizedSource.length) {
const index = normalizedSource.indexOf(token, start);
if (index < 0) break;
const first = sourceRanges[index];
const last = sourceRanges[index + token.length - 1];
if (first && last) matches.push({ start: first.start, end: last.end });
start = index + Math.max(1, token.length);
}
}
matches.sort((left, right) => left.start - right.start || left.end - right.end);
const merged = [];
for (const match of matches) {
const previous = merged.at(-1);
if (previous && match.start <= previous.end) previous.end = Math.max(previous.end, match.end);
else merged.push({ ...match });
}
let cursor = 0;
for (const match of merged) {
if (match.start > cursor) container.append(source.slice(cursor, match.start));
if (match.end > match.start) {
const mark = document.createElement('mark');
mark.textContent = source.slice(match.start, match.end);
container.appendChild(mark);
}
cursor = Math.max(cursor, match.end);
}
if (cursor < source.length) container.append(source.slice(cursor));
};
const showSettingsNavigation = () => {
navigation.hidden = false;
layout.querySelector('#settingsSearchResults').hidden = true;
layout.querySelector('#settingsSearchEmpty').hidden = true;
_syncSidebarIndicator(navigation.querySelector('.settings-nav-button.active'), true);
};
const openSettingsSearchEntry = (entry) => {
searchInput.value = '';
showSettingsNavigation();
activateSettingsPage(entry.pageId);
requestAnimationFrame(() => {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
entry.targetElement.scrollIntoView({ block: 'center', behavior: reducedMotion ? 'auto' : 'smooth' });
entry.targetElement.classList.remove('settings-search-target-highlight');
entry.targetElement.classList.add('settings-search-target-highlight');
entry.targetControl?.focus({ preventScroll: true });
setTimeout(() => entry.targetElement.classList.remove('settings-search-target-highlight'), 1800);
});
};
const renderSettingsSearchResults = (query) => {
const results = layout.querySelector('#settingsSearchResults');
const empty = layout.querySelector('#settingsSearchEmpty');
const status = layout.querySelector('#settingsSearchStatus');
const rawTokens = query.trim().split(/\s+/u).filter(Boolean);
const normalizedTokens = rawTokens.map(normalizeSettingsSearchText);
if (normalizedTokens.length === 0) {
results.replaceChildren();
status.textContent = '';
showSettingsNavigation();
return;
}
const matches = searchEntries.filter(entry => normalizedTokens.every(token => entry.searchText.includes(token)));
navigation.hidden = true;
results.hidden = matches.length === 0;
empty.hidden = matches.length > 0;
status.textContent = `${localizeUiText('Suchergebnisse:')} ${matches.length}`;
results.replaceChildren();
for (const entry of matches) {
const item = document.createElement('div');
item.className = 'settings-search-result-item';
item.setAttribute('role', 'listitem');
const button = document.createElement('button');
button.type = 'button';
button.className = 'settings-search-result';
const path = document.createElement('span');
path.className = 'settings-search-result-path';
const segments = [entry.pageLabel, entry.section, entry.title].map(localizeUiText);
segments.forEach((segment, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.className = 'settings-search-result-separator';
separator.textContent = ' → ';
separator.setAttribute('aria-hidden', 'true');
path.appendChild(separator);
}
appendSettingsSearchHighlight(path, segment, rawTokens);
});
button.appendChild(path);
button.addEventListener('click', () => openSettingsSearchEntry(entry));
item.appendChild(button);
results.appendChild(item);
}
};
navigation.addEventListener('click', (event) => { navigation.addEventListener('click', (event) => {
const button = event.target.closest('[data-settings-page]'); const button = event.target.closest('[data-settings-page]');
if (button) activateSettingsPage(button.dataset.settingsPage); if (button) activateSettingsPage(button.dataset.settingsPage);
@@ -5315,28 +5493,8 @@ function renderSettings() {
}); });
const searchInput = layout.querySelector('#settingsSearchInput'); const searchInput = layout.querySelector('#settingsSearchInput');
const searchEmpty = layout.querySelector('#settingsSearchEmpty');
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
const query = searchInput.value.trim().toLocaleLowerCase(getUiLocale()); renderSettingsSearchResults(searchInput.value);
const visibleButtons = [];
navigation.querySelectorAll('.settings-nav-button').forEach((button) => {
const visible = !query || button.dataset.search.includes(query);
button.hidden = !visible;
if (visible) visibleButtons.push(button);
});
searchEmpty.hidden = visibleButtons.length > 0;
const activeButton = navigation.querySelector('.settings-nav-button.active');
if (visibleButtons.length === 0) {
Object.values(pages).forEach((page) => page.classList.remove('active'));
const indicator = navigation.querySelector('.settings-nav-indicator');
if (indicator) indicator.hidden = true;
return;
}
if (!activeButton || activeButton.hidden || !content.querySelector('.settings-subpage.active')) {
activateSettingsPage((activeButton && !activeButton.hidden ? activeButton : visibleButtons[0]).dataset.settingsPage);
} else {
_syncSidebarIndicator(activeButton, true);
}
}); });
_renderLogPathsList(document.getElementById('logPathsList')); _renderLogPathsList(document.getElementById('logPathsList'));
+5
View File
@@ -225,6 +225,9 @@
['Warte auf Account-Prüfung…', 'Waiting for account check…'], ['Warte auf Account-Prüfung…', 'Waiting for account check…'],
['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'], ['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'],
['Quelldateien', 'Source files'], ['Quelldateien', 'Source files'],
['Nach erfolgreichem Upload löschen', 'Delete after successful upload'],
['Suchergebnisse', 'Search results'],
['Suchergebnisse:', 'Search results:'],
['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'], ['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'],
['Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet.', 'Permanently deletes the original file after all selected hosts have completed successfully. The Recycle Bin is not used.'], ['Löscht die Originaldatei endgültig, sobald alle dafür ausgewählten Hoster erfolgreich abgeschlossen sind. Der Papierkorb wird nicht verwendet.', 'Permanently deletes the original file after all selected hosts have completed successfully. The Recycle Bin is not used.'],
['Quelldateien dauerhaft löschen?', 'Permanently delete source files?'], ['Quelldateien dauerhaft löschen?', 'Permanently delete source files?'],
@@ -311,6 +314,8 @@
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'], ['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
['Schlüssel kopieren', 'Copy key'], ['Schlüssel kopieren', 'Copy key'],
['Online-Backup löschen', 'Delete online backup'], ['Online-Backup löschen', 'Delete online backup'],
['Online-Backup', 'Online backup'],
['Online-Backups verwalten', 'Manage online backups'],
['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'], ['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'],
['Schlüssel gelöscht', 'Key deleted'], ['Schlüssel gelöscht', 'Key deleted'],
['Erneut laden', 'Reload'], ['Erneut laden', 'Reload'],
+60
View File
@@ -1060,6 +1060,65 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
color: var(--text); color: var(--text);
} }
.settings-nav-button[hidden] { display: none; } .settings-nav-button[hidden] { display: none; }
.settings-navigation[hidden],
.settings-search-results[hidden] { display: none; }
.settings-search-results {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.settings-search-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.settings-search-result-item { display: block; }
.settings-search-result {
display: block;
width: 100%;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 11px;
line-height: 1.45;
text-align: left;
cursor: pointer;
}
.settings-search-result:hover {
border-color: var(--border-hover);
background: var(--bg-card-hover);
color: var(--text);
}
.settings-search-result-path {
display: block;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.settings-search-result-separator { color: var(--text-dim); }
.settings-search-result mark {
padding: 0 2px;
border-radius: 3px;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--text);
font-weight: 700;
}
.settings-search-target-highlight {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 72%, transparent);
transition: background-color .2s ease, box-shadow .2s ease;
}
.settings-search-empty { margin: 4px; color: var(--text-dim); font-size: 11px; line-height: 1.45; } .settings-search-empty { margin: 4px; color: var(--text-dim); font-size: 11px; line-height: 1.45; }
.settings-content { .settings-content {
min-width: 0; min-width: 0;
@@ -1828,6 +1887,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.settings-search-target-highlight { transition: none; }
*, *::before, *::after { *, *::before, *::after {
scroll-behavior: auto !important; scroll-behavior: auto !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
+6
View File
@@ -53,6 +53,12 @@ test('translates the account check timestamp label', () => {
assert.equal(translateText('checked', 'de'), 'geprüft'); assert.equal(translateText('checked', 'de'), 'geprüft');
}); });
test('translates settings search result labels in both directions', () => {
assert.equal(translateText('Nach erfolgreichem Upload löschen', 'en'), 'Delete after successful upload');
assert.equal(translateText('Delete after successful upload', 'de'), 'Nach erfolgreichem Upload löschen');
assert.equal(translateText('Suchergebnisse', 'en'), 'Search results');
});
test('translates account cooldown and manual pause labels', () => { test('translates account cooldown and manual pause labels', () => {
const pairs = [ const pairs = [
['Pausiert noch', 'Paused '], ['Pausiert noch', 'Paused '],
+123
View File
@@ -96,6 +96,9 @@ contextBridge.exposeInMainWorld('api', {
onUpdateProgress() {}, onUpdateProgress() {},
onPrepareClose() {}, onPrepareClose() {},
getConfig() { return new Promise(() => {}); }, getConfig() { return new Promise(() => {}); },
remoteStatus() { return Promise.resolve({ running: false, port: 0, clientCount: 0 }); },
diagnosticsStatus() { return Promise.resolve({ running: false }); },
diagnosticsGetSettings() { return Promise.resolve({}); },
listManagedOnlineBackups() { listManagedOnlineBackups() {
managedOnlineBackupListIndex++; managedOnlineBackupListIndex++;
managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]); managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]);
@@ -188,6 +191,76 @@ contextBridge.exposeInMainWorld('api', {
rebuildJobIndex(); rebuildJobIndex();
return { safeFocus, safeResult, removalFocus, removalEnterResult, cancelFocusedEnter, cancelResult, removalCalls }; return { safeFocus, safeResult, removalFocus, removalEnterResult, cancelFocusedEnter, cancelResult, removalCalls };
})()`; })()`;
const settingsSearchBehaviorScript = `(async () => {
setUiLanguage('de');
document.querySelector('[data-view="settings"]')?.click();
renderSettings();
await new Promise(resolve => setTimeout(resolve, 0));
const search = document.getElementById('settingsSearchInput');
search.value = 'erfolgreich löschen';
search.dispatchEvent(new Event('input', { bubbles: true }));
const resultButtons = [...document.querySelectorAll('.settings-search-result')];
const targetResult = resultButtons.find(button => button.textContent.includes('Nach erfolgreichem Upload löschen'));
const germanState = {
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
resultsHidden: document.getElementById('settingsSearchResults')?.hidden,
count: resultButtons.length,
path: targetResult?.querySelector('.settings-search-result-path')?.textContent.trim(),
marks: [...(targetResult?.querySelectorAll('mark') || [])].map(mark => mark.textContent.toLowerCase()),
liveStatus: document.getElementById('settingsSearchStatus')?.textContent.trim()
};
const inspectPaths = value => {
search.value = value;
search.dispatchEvent(new Event('input', { bubbles: true }));
return [...document.querySelectorAll('.settings-search-result-path')].map(element => element.textContent.trim());
};
const updatePaths = inspectPaths('update');
const exceptionalPaths = [
'schlüssel importieren',
'neuen schlüssel erzeugen',
'online-backups verwalten',
'datei exportieren',
'datei importieren'
].map(value => inspectPaths(value));
const stableSectionPath = inspectPaths('ordnerpfad')[0];
const umlautAliasPath = inspectPaths('loeschen').find(path => path.includes('Nach erfolgreichem Upload löschen'));
search.value = 'online-backups verwalten';
search.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('.settings-search-result')?.click();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const managedBackupNavigation = {
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
focus: document.activeElement?.id,
highlighted: document.querySelector('.online-backup-managed')?.classList.contains('settings-search-target-highlight')
};
search.value = 'erfolgreich löschen';
search.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('.settings-search-result')?.click();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const navigationState = {
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
focus: document.activeElement?.id,
highlighted: document.querySelector('.source-delete-option')?.classList.contains('settings-search-target-highlight'),
searchValue: search.value,
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
resultsHidden: document.getElementById('settingsSearchResults')?.hidden
};
setUiLanguage('en');
search.value = 'delete source';
search.dispatchEvent(new Event('input', { bubbles: true }));
const englishPath = [...document.querySelectorAll('.settings-search-result-path')]
.map(element => element.textContent.trim())
.find(text => text.includes('Delete after successful upload'));
search.value = '';
search.dispatchEvent(new Event('input', { bubbles: true }));
const clearedState = {
page: document.querySelector('.settings-subpage.active')?.dataset.subpage,
navigationHidden: document.querySelector('.settings-navigation')?.hidden,
resultsHidden: document.getElementById('settingsSearchResults')?.hidden,
emptyHidden: document.getElementById('settingsSearchEmpty')?.hidden
};
return { germanState, updatePaths, exceptionalPaths, stableSectionPath, umlautAliasPath, managedBackupNavigation, navigationState, englishPath, clearedState };
})()`;
const onlineBackupBehaviorScript = `(async () => { const onlineBackupBehaviorScript = `(async () => {
const ids = { const ids = {
a: 'AAAAAAAAAAAAAAAAAAAAAA', a: 'AAAAAAAAAAAAAAAAAAAAAA',
@@ -392,6 +465,7 @@ app.whenReady().then(async () => {
const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })'); const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })');
const appDialogBehavior = await window.webContents.executeJavaScript(${JSON.stringify(appDialogBehaviorScript)}); const appDialogBehavior = await window.webContents.executeJavaScript(${JSON.stringify(appDialogBehaviorScript)});
const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)}); const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)});
const settingsSearchBehavior = await window.webContents.executeJavaScript(${JSON.stringify(settingsSearchBehaviorScript)});
const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)}); const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)});
window.setContentSize(760, Math.min(900, display.workAreaSize.height)); window.setContentSize(760, Math.min(900, display.workAreaSize.height));
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
@@ -408,6 +482,7 @@ app.whenReady().then(async () => {
rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY), rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY),
liveSpeedChart, liveSpeedChart,
appDialogBehavior, appDialogBehavior,
settingsSearchBehavior,
onlineBackupBehavior, onlineBackupBehavior,
onlineBackupLayout, onlineBackupLayout,
onlineBackupNarrowLayout onlineBackupNarrowLayout
@@ -422,6 +497,7 @@ app.whenReady().then(async () => {
fs.writeFileSync(probePath, probeSource, 'utf8'); fs.writeFileSync(probePath, probeSource, 'utf8');
try { try {
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
try {
execFileSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], { execFileSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
cwd: projectRoot, cwd: projectRoot,
env: { env: {
@@ -436,6 +512,13 @@ app.whenReady().then(async () => {
timeout: 30000, timeout: 30000,
windowsHide: true windowsHide: true
}); });
} catch (error) {
if (fs.existsSync(outputPath)) {
const failedResult = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
assert.fail(failedResult.error || error.message);
}
throw error;
}
const result = JSON.parse(fs.readFileSync(outputPath, 'utf8')); const result = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
assert.equal(result.error, undefined); assert.equal(result.error, undefined);
assert.doesNotMatch(result.rendererCommandLine, /--disable-gpu-compositing/u); assert.doesNotMatch(result.rendererCommandLine, /--disable-gpu-compositing/u);
@@ -464,6 +547,46 @@ app.whenReady().then(async () => {
{ title: 'Alle Uploads entfernen?', defaultAction: 'confirm' } { title: 'Alle Uploads entfernen?', defaultAction: 'confirm' }
] ]
}); });
assert.deepEqual(result.settingsSearchBehavior, {
germanState: {
navigationHidden: true,
resultsHidden: false,
count: 1,
path: 'Uploads → Quelldateien → Nach erfolgreichem Upload löschen',
marks: ['erfolgreich', 'löschen'],
liveStatus: 'Suchergebnisse: 1'
},
updatePaths: ['Allgemein → Programmupdate → Nach Updates suchen'],
exceptionalPaths: [
['Backup & Übertragen → Online-Backup → Schlüssel importieren'],
['Backup & Übertragen → Online-Backup → Neuen Schlüssel erzeugen'],
['Backup & Übertragen → Online-Backup → Online-Backups verwalten'],
['Backup & Übertragen → Lokales Datei-Backup → Datei exportieren'],
['Backup & Übertragen → Lokales Datei-Backup → Datei importieren']
],
stableSectionPath: 'Automatik → Ordnerüberwachung → Ordnerpfad',
umlautAliasPath: 'Uploads → Quelldateien → Nach erfolgreichem Upload löschen',
managedBackupNavigation: {
page: 'backup',
focus: 'managedOnlineBackupHeading',
highlighted: true
},
navigationState: {
page: 'uploads',
focus: 'deleteSourceAfterSuccessfulUploadInput',
highlighted: true,
searchValue: '',
navigationHidden: false,
resultsHidden: true
},
englishPath: 'Uploads → Source files → Delete after successful upload',
clearedState: {
page: 'uploads',
navigationHidden: false,
resultsHidden: true,
emptyHidden: true
}
});
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']); assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
assert.deepEqual(result.onlineBackupBehavior.initialWarning, { assert.deepEqual(result.onlineBackupBehavior.initialWarning, {
hidden: false, hidden: false,
+5 -5
View File
@@ -1389,8 +1389,8 @@ setTimeout(async () => {
fs.writeFileSync(process.env.MHU_SETTINGS_SCREENSHOT, screenshot.toPNG()); fs.writeFileSync(process.env.MHU_SETTINGS_SCREENSHOT, screenshot.toPNG());
} }
const settingsSearchState = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); if (!search) return "missing"; search.value = "fertig"; search.dispatchEvent(new Event("input", { bubbles: true })); const visible = [...document.querySelectorAll(".settings-nav-button")].filter(button => !button.hidden); return [visible.map(button => button.dataset.settingsPage).join(","), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage].join("|"); })()'); const settingsSearchState = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); if (!search) return "missing"; search.value = "erfolgreich löschen"; search.dispatchEvent(new Event("input", { bubbles: true })); const result = document.querySelector(".settings-search-result"); return [document.querySelector(".settings-navigation").hidden, document.getElementById("settingsSearchResults").hidden, result?.textContent.trim(), [...(result?.querySelectorAll("mark") || [])].map(mark => mark.textContent.toLowerCase()).join(",")].join("|"); })()');
check('Settings search routes completion terms to Uploads first', settingsSearchState === 'uploads,benachrichtigungen|uploads'); check('Settings search shows a highlighted breadcrumb for the matching setting', settingsSearchState === 'true|false|Uploads → Quelldateien → Nach erfolgreichem Upload löschen|erfolgreich,löschen');
const settingsSearchRecovery = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); search.value = "kein-passender-treffer"; search.dispatchEvent(new Event("input", { bubbles: true })); const emptyVisible = !document.getElementById("settingsSearchEmpty").hidden; search.value = ""; search.dispatchEvent(new Event("input", { bubbles: true })); return [emptyVisible, document.querySelector(".settings-subpage.active")?.dataset.subpage, document.getElementById("settingsSearchEmpty").hidden].join("|"); })()'); const settingsSearchRecovery = await wc.executeJavaScript('(() => { const search = document.getElementById("settingsSearchInput"); search.value = "kein-passender-treffer"; search.dispatchEvent(new Event("input", { bubbles: true })); const emptyVisible = !document.getElementById("settingsSearchEmpty").hidden; search.value = ""; search.dispatchEvent(new Event("input", { bubbles: true })); return [emptyVisible, document.querySelector(".settings-subpage.active")?.dataset.subpage, document.getElementById("settingsSearchEmpty").hidden].join("|"); })()');
check('Clearing an empty settings search restores the current page', settingsSearchRecovery === 'true|uploads|true'); check('Clearing an empty settings search restores the current page', settingsSearchRecovery === 'true|uploads|true');
@@ -2054,15 +2054,15 @@ setTimeout(async () => {
const inspect = value => { const inspect = value => {
search.value = value; search.value = value;
search.dispatchEvent(new Event('input', { bubbles: true })); search.dispatchEvent(new Event('input', { bubbles: true }));
return [...document.querySelectorAll('.settings-nav-button')].filter(button => !button.hidden).map(button => button.dataset.settingsPage); return [...document.querySelectorAll('.settings-search-result-path')].map(element => element.textContent.trim());
}; };
const notifications = inspect('notifications'); const notifications = inspect('notifications');
const windowSettings = inspect('window'); const windowSettings = inspect('always on top');
search.value = ''; search.value = '';
search.dispatchEvent(new Event('input', { bubbles: true })); search.dispatchEvent(new Event('input', { bubbles: true }));
return { notifications, windowSettings }; return { notifications, windowSettings };
})()\`); })()\`);
check('English settings search finds localized concepts', englishSettingsSearch.notifications.includes('benachrichtigungen') && englishSettingsSearch.windowSettings.includes('allgemein')); check('English settings search finds localized concepts', englishSettingsSearch.notifications.some(path => path.startsWith('Notifications →')) && englishSettingsSearch.windowSettings.some(path => path.startsWith('General →')));
const productNaming = await wc.executeJavaScript(\`(() => ({ const productNaming = await wc.executeJavaScript(\`(() => ({
title: document.title, title: document.title,