Unify automation settings layout and move test action to footer
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 06:03:28 +02:00
parent c86605d67b
commit 9d696fa89f
5 changed files with 195 additions and 24 deletions
+2
View File
@@ -6,6 +6,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
## Aktueller Zustand
- Automatik-Einstellungen nach `2.1.49`: gemeinsames 210-px-Beschriftungsraster für Wiederholungen, Ordnerpfad, Queue-Limit, Abgleichintervall, Dateifilter und Verzögerung. Felder 40 px hoch, Zahlenfelder 120 px breit, Dropdowns auf passende Breiten begrenzt und Pfeile 14 px nach innen versetzt. Hinweise stehen unter ihrem jeweiligen Feld; Container-Abfragen schalten bei schmalem Inhalt auf einspaltige Darstellung. Ordnerüberwachungstest steht nach Verhalten/Hoster-Vorauswahl als Abschlussaktion. IDs und Speicherlogik unverändert. Electron-Regression mit echtem Template bei 1000/760/360 px, Screenshot visuell geprüft; noch kein Release.
- Online-Backup-Seite nach `2.1.49`: drei klar abgegrenzte Bereiche in Reihenfolge „Schlüssel erstellen“, „Schlüssel importieren“, „Auf diesem Gerät erstellt“. Vorhandene IDs, Import-/Exportlogik, Statusmeldungen und Suchziele bleiben erhalten. Neue Überschriften auf Deutsch/Englisch; konsistente Karten ohne Akzentstreifen. Electron-Test nutzt jetzt das echte Seitenmarkup und prüft Modulzuordnung, Ausrichtung sowie Überlauf bei 1000/600/360 px. Lokale Entwicklerversion automatisch neu gestartet; breiter Screenshot visuell geprüft. Noch nicht veröffentlicht.
- Lokale Entwicklung auf Nutzerwunsch dauerhaft mit automatischem Neustart: `.artifacts/dev-watch.cjs` überwacht Main-/Preload-Dateien, `lib`, `renderer` und den lokalen Bootstrap `.artifacts/menu-test.cjs`. Start über `node .artifacts/dev-watch.cjs`; separater Fenstertitel „Multi-Hoster Entwicklung (automatischer Neustart)“. Persistentes Testprofil unter `%LOCALAPPDATA%/Multi-Hoster-Upload-Development`, ohne automatische Accountchecks/Ordnerüberwachung/Remote-Steuerung initialisiert. Installierte Anwendung bleibt getrennt. Neustart durch CSS-Dateiänderung anhand gewechselter Electron-PID und erneut sichtbarem Fenster verifiziert. Bei weiteren Code-/UI-Änderungen diesen Watcher verwenden und den Neustart prüfen.
- Online-Backup-Gültigkeitsdauer nach `2.1.49`, noch nicht veröffentlicht: Auswahl und Erstellen-Button verwenden 40 px Höhe und 14 px Schrift; Label und Auswahl sind zur Importzeile ausgerichtet. Footer bricht bei Platzmangel um, auf schmalen Fenstern untereinander. Leere Statuszeile belegt keinen Platz; echte Meldungen bleiben sichtbar. Nativer Select und 14 px Pfeil-Innenabstand bleiben erhalten. Electron-Layoutregression bei 1000, 600 und 360 px; Screenshot der breiten Darstellung visuell geprüft.
@@ -92,6 +93,7 @@ npm audit --omit=dev
## Zuletzt verifiziert
- Automatik-Layout: 840 Haupttests, 18 Servertests und Lint erfolgreich. Feldhöhen, gemeinsame linke Kante, Label-/Hinweispositionen, Dropdownbreiten/Pfeilabstand und Abschlussposition der Testaktion über echte Electron-DOM-Messungen geprüft. Watcher-Neustart und sichtbare Entwicklungsinstanz bestätigt. Noch nicht veröffentlicht.
- Getrennte Online-Backup-Module: 839 Haupttests, 18 Servertests und Lint erfolgreich. Echtes Modulmarkup in Electron bei drei Fensterbreiten geprüft; Entwicklerversion nach Renderer-Änderung mit neuem Prozess sichtbar bestätigt. Noch kein Release.
- Gültigkeitsdauer-Layout: neue Electron-Regression erfolgreich, Screenshot visuell geprüft, Lint und 18 Servertests erfolgreich. Hauptlauf 838/839 erfolgreich: Menü-Fokustest reagierte einmal zu früh auf asynchronen Fokus. Test wartet jetzt begrenzt auf den tatsächlichen Fokus statt pauschal 240 ms; kompletter Wiederholungslauf der betroffenen Datei 25/25 erfolgreich. Kein Release erstellt.
- Backup-Untermenü nach `2.1.49`: 838 Haupttests, 18 Servertests und Lint erfolgreich. Gezielter Electron-Test nach abschließender Fokuskorrektur erneut erfolgreich; ein Mausklick auf den Trigger hält das Menü beim Verlassen nicht unbeabsichtigt offen. Änderungen noch nicht veröffentlicht.
+37 -23
View File
@@ -6377,58 +6377,65 @@ function renderSettings() {
<strong id="automationLastError"></strong>
</div>
</section>
<div class="automation-form-fields">
<div class="settings-section-label">Unbeaufsichtigter Betrieb</div>
<div class="settings-row automation-retry-row">
<div class="settings-row automation-retry-row automation-field-row">
<label for="autoRetryRoundsInput">Automatische Wiederholungsrunden</label>
<div class="automation-retry-control">
<div class="automation-retry-control automation-field-control">
<input type="number" class="hs-input settings-autosave" id="autoRetryRoundsInput" min="0" max="5" value="${Number(globalSettings.autoRetryRounds) || 0}">
<span class="hint">0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht.</span>
</div>
</div>
<div class="settings-row automation-retry-row">
<div class="settings-row automation-retry-row automation-field-row">
<label for="autoRetryDelayMinInput">Wartezeit zwischen Runden</label>
<div class="automation-retry-control">
<div class="automation-retry-control automation-field-control">
<input type="number" class="hs-input settings-autosave" id="autoRetryDelayMinInput" min="1" max="120" value="${Number(globalSettings.autoRetryDelayMin) || 5}">
<span class="hint">Minuten · jede weitere Runde wartet entsprechend länger</span>
</div>
</div>
<div class="settings-section-label">Ordnerüberwachung</div>
<div class="settings-row">
<label>Ordnerpfad</label>
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen..." style="flex:1">
<div class="settings-row automation-field-row">
<label for="fmFolderPathInput">Ordnerpfad</label>
<div class="automation-inline-controls automation-folder-controls">
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen...">
<button class="btn btn-xs btn-secondary" id="fmChooseFolderBtn">Wählen</button>
</div>
<div class="settings-row automation-capacity-row">
</div>
<div class="settings-row automation-capacity-row automation-field-row">
<label for="fmQueueLimitInput">Maximale automatische Queue-Größe</label>
<div class="automation-field-control">
<input type="number" class="hs-input settings-autosave" id="fmQueueLimitInput" value="${normalizedFm.queueLimitJobs}" min="0" step="1">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-row automation-interval-row">
</div>
<div class="settings-row automation-interval-row automation-field-row">
<label for="fmReconcileIntervalInput">Abgleichintervall</label>
<span class="automation-select-control">
<select class="hs-input settings-autosave" id="fmReconcileIntervalInput">
${[1, 5, 15, 30, 60].map(value => `<option value="${value}" ${normalizedFm.reconcileIntervalMinutes === value ? 'selected' : ''}>${value === 1 ? '1 Minute' : `${value} Minuten`}</option>`).join('')}
</select>
</span>
</div>
<div class="settings-row automation-test-action-row">
<div class="automation-test-action-copy">
<strong>Ordnerüberwachung testen</strong>
<span class="hint">Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.</span>
</div>
<button class="btn btn-secondary" id="automationTestBtn" type="button">Ordnerüberwachung testen</button>
</div>
<div class="settings-row">
<label>Dateierweiterungen</label>
<select class="hs-input settings-autosave" id="fmFilterModeInput" style="width:auto;margin-right:6px">
<div class="settings-row automation-field-row">
<label for="fmFilterModeInput">Dateierweiterungen</label>
<div class="automation-inline-controls automation-filter-controls">
<span class="automation-select-control">
<select class="hs-input settings-autosave" id="fmFilterModeInput">
<option value="include" ${fm.filterMode === 'include' ? 'selected' : ''}>Nur diese</option>
<option value="exclude" ${fm.filterMode === 'exclude' ? 'selected' : ''}>Alle außer</option>
</select>
<input type="text" class="key-input settings-autosave" id="fmExtensionsInput" value="${escapeAttr(fm.extensions || '')}" placeholder="mp4,mkv,avi" style="flex:1">
</span>
<input type="text" class="key-input settings-autosave" id="fmExtensionsInput" aria-label="Dateierweiterungen" value="${escapeAttr(fm.extensions || '')}" placeholder="mp4,mkv,avi">
</div>
<div class="settings-row">
<label>Verzögerung (Sekunden)</label>
<input type="number" class="hs-input settings-autosave" id="fmDelaySecInput" value="${fm.delaySec ?? 3}" min="1" max="300" style="width:80px">
</div>
<div class="settings-row automation-field-row">
<label for="fmDelaySecInput">Verzögerung (Sekunden)</label>
<div class="automation-field-control">
<input type="number" class="hs-input settings-autosave" id="fmDelaySecInput" value="${fm.delaySec ?? 3}" min="1" max="300">
<span class="hint">Warten bis Datei fertig geschrieben</span>
</div>
</div>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row folder-monitor-help-row">
@@ -6466,6 +6473,13 @@ function renderSettings() {
</div>`).join('')}
</div>
${configuredAccounts.length === 0 ? '<p class="hint" style="margin:0">Erst Accounts anlegen, dann hier auswählen.</p>' : '<p class="hint" style="margin:2px 0 0">Keine Vorauswahl = manuelle Hoster-Auswahl für neu erkannte Dateien.</p>'}
<div class="settings-row automation-test-action-row">
<div class="automation-test-action-copy">
<strong>Ordnerüberwachung testen</strong>
<span class="hint">Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.</span>
</div>
<button class="btn btn-secondary" id="automationTestBtn" type="button">Ordnerüberwachung testen</button>
</div>
`;
pages.benachrichtigungen.innerHTML = `
+107
View File
@@ -4889,3 +4889,110 @@ input[type="checkbox"] {
}
.automation-queue-bar { transition: none; }
}
.automation-form-fields {
container: automation-fields / inline-size;
min-width: 0;
}
.settings-subpage .settings-row.automation-field-row {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
align-items: start;
gap: 12px;
}
.settings-subpage .settings-row.automation-field-row > label {
min-width: 0;
padding-top: 9px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.settings-subpage .automation-field-control {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.settings-subpage .automation-field-control > .hint {
margin: 0;
padding: 0;
line-height: 1.5;
}
.settings-subpage .automation-field-row :is(.hs-input, .key-input) {
min-width: 0;
max-width: none;
width: 100%;
min-height: 40px;
border-radius: 6px;
}
.settings-subpage .automation-field-row input[type="number"] {
width: 120px;
}
.automation-inline-controls {
display: grid;
align-items: start;
gap: 12px;
min-width: 0;
}
.automation-folder-controls {
grid-template-columns: minmax(0, 1fr) auto;
}
.automation-filter-controls {
grid-template-columns: minmax(0, 180px) minmax(0, 1fr);
}
.automation-select-control {
display: block;
position: relative;
width: 100%;
max-width: 240px;
min-width: 0;
}
.automation-select-control::after {
content: '';
position: absolute;
top: 50%;
right: 14px;
width: 7px;
height: 7px;
border-right: 2px solid var(--text-dim);
border-bottom: 2px solid var(--text-dim);
transform: translateY(-65%) rotate(45deg);
pointer-events: none;
}
.settings-subpage .automation-select-control > select {
appearance: none;
padding-right: 38px;
user-select: none;
}
.settings-subpage .automation-test-action-row {
margin-top: 20px;
}
@container automation-fields (max-width: 620px) {
.settings-subpage .settings-row.automation-field-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.settings-subpage .settings-row.automation-field-row > label {
padding-top: 0;
}
}
@container automation-fields (max-width: 380px) {
.automation-filter-controls {
grid-template-columns: minmax(0, 1fr);
}
}
+48
View File
@@ -222,6 +222,54 @@ app.whenReady().then(async () => {
assert.equal(data.ok, true);
});
test('automation fields share a responsive grid and leave hints below their controls', { skip: process.platform !== 'win32' }, t => {
const root = path.resolve(__dirname, '..');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-layout-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const probe = path.join(directory, 'probe.cjs');
const result = path.join(directory, 'result.json');
fs.writeFileSync(probe, `
const {app,BrowserWindow}=require('electron');
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert/strict');
app.whenReady().then(async()=>{
const root=process.env.MHU_AUTOMATION_ROOT;
const css=fs.readFileSync(path.join(root,'renderer/styles.css'),'utf8');
const source=fs.readFileSync(path.join(root,'renderer/app.js'),'utf8');
const context={pages:{automatik:{}},pageHeader:()=>'',folderMonitorHelp:()=>'',globalSettings:{},fm:{},normalizedFm:{queueLimitJobs:15000,reconcileIntervalMinutes:5},configuredAccounts:[],escapeAttr:String,escapeHtml:String};
vm.runInNewContext(source.slice(source.indexOf('pages.automatik.innerHTML ='),source.indexOf('pages.benachrichtigungen.innerHTML =')),context);
const win=new BrowserWindow({show:false,width:1000,height:1100,webPreferences:{backgroundThrottling:false}});
const wc=win.webContents;
for(const width of [1000,760,360]){
win.setContentSize(width,1100);
await wc.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent('<style>'+css+'</style><main id="settings-view" style="display:block;padding:16px;width:100%"><div class="settings-subpage" style="display:block;width:100%">'+context.pages.automatik.innerHTML+'</div></main>'));
await wc.executeJavaScript('document.querySelector(".automation-status-card").remove()');
const data=await wc.executeJavaScript('(() => { const rect=e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height}};const rows=Array.from(document.querySelectorAll(".automation-field-row")).map(row=>({label:rect(row.querySelector("label")),input:rect(row.querySelector("input,select")),hint:row.querySelector(".hint")?rect(row.querySelector(".hint")):null}));return {rows,numbers:Array.from(document.querySelectorAll(".automation-field-row input[type=number]")).map(rect),selects:Array.from(document.querySelectorAll(".automation-select-control")).map(e=>({width:rect(e).width,arrow:getComputedStyle(e,"::after").right})),overflow:document.documentElement.scrollWidth>innerWidth,last:document.querySelector(".settings-subpage").lastElementChild.classList.contains("automation-test-action-row")};})()');
assert.equal(data.overflow,false);
assert.equal(data.last,true);
const left=data.rows[0].input.left;
for(const row of data.rows){
assert.ok(Math.abs(row.input.left-left)<1);
assert.equal(row.input.height,40);
if(width===360) assert.ok(row.input.top>=row.label.bottom);
else assert.ok(row.label.right<=row.input.left);
if(row.hint){assert.ok(row.hint.top>=row.input.bottom+6);assert.ok(Math.abs(row.hint.left-row.input.left)<1)}
}
for(const number of data.numbers) assert.equal(number.width,120);
for(const select of data.selects){assert.ok(select.width<=240);assert.equal(select.arrow,'14px')}
if(process.env.MHU_AUTOMATION_SCREENSHOT && width===1000){await new Promise(resolve=>setTimeout(resolve,500));fs.writeFileSync(process.env.MHU_AUTOMATION_SCREENSHOT,(await wc.capturePage()).toPNG())}
}
win.destroy();fs.writeFileSync(process.env.MHU_AUTOMATION_RESULT,JSON.stringify({ok:true}));app.exit(0);
}).catch(error=>{fs.writeFileSync(process.env.MHU_AUTOMATION_RESULT,JSON.stringify({error:error.stack}));app.exit(1)});
`);
const execution = spawnSync(path.join(root, 'node_modules/electron/dist/electron.exe'), [probe, '--user-data-dir=' + path.join(directory, 'profile')], {
cwd: root, windowsHide: true, encoding: 'utf8', timeout: 20000,
env: { ...process.env, MHU_AUTOMATION_ROOT: root, MHU_AUTOMATION_RESULT: result }
});
const data = fs.existsSync(result) ? JSON.parse(fs.readFileSync(result, 'utf8')) : {};
assert.equal(execution.status, 0, data.error || execution.stderr);
assert.equal(data.ok, true);
});
class TestBrowserWindow extends EventEmitter {
constructor(options) {
super();
+1 -1
View File
@@ -1370,7 +1370,7 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()');
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|120|120|true|true|true|true');
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 16, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
check('Program update action sits at the lower right of its card', updateActionAlignment === 'true|true|true');