diff --git a/renderer/app.js b/renderer/app.js index e2a8fe8..953c443 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -723,12 +723,10 @@ async function applyAutomationEvaluation(evaluation) { if (await isAutomationPaused()) { return freezeAutomationValue({ ok: false, error: 'Automatik ist pausiert', warning: null, admittedFiles: [], deferredFiles, paused: true, dryRun: false }); } - const cleanupPreparation = prepareSourceCleanup(newJobs); + const addRequest = createAddJobsRequest(newJobs); + const cleanupPreparation = addRequest.cleanupPreparation; try { - const result = await window.api.addJobsToBatch({ - jobs: newJobs.map(serializeUploadJob), - sourceCleanupGroups: cleanupPreparation.groups - }); + const result = await submitAddJobsRequest(addRequest); if (result?.error) { restoreSourceCleanupStates(cleanupPreparation.rollbackStates); const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); @@ -1529,12 +1527,10 @@ async function applyHosterSelection() { if (uploading && pendingPaths.size > 0 && !(await isAutomationPaused())) { const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); if (newJobs.length > 0) { - const cleanupPreparation = prepareSourceCleanup(newJobs); + const addRequest = createAddJobsRequest(newJobs); + const cleanupPreparation = addRequest.cleanupPreparation; try { - const result = await window.api.addJobsToBatch({ - jobs: newJobs.map(serializeUploadJob), - sourceCleanupGroups: cleanupPreparation.groups - }); + const result = await submitAddJobsRequest(addRequest); if (result?.error) { regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); restoreSourceCleanupStates(cleanupPreparation.rollbackStates); @@ -3789,13 +3785,54 @@ function sanitizeUploadControlError(error, fallback) { return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback; } -function resolveAddJobsOutcome(jobs, result) { +function analyzeAddJobsInput(jobs) { const inputJobs = Array.isArray(jobs) ? jobs : []; - const jobsById = new Map(inputJobs.filter(job => job?.id).map(job => [job.id, job])); + const idCounts = new Map(); + for (const job of inputJobs) { + const id = job?.id; + if (typeof id !== 'string' || id.trim().length === 0) continue; + idCounts.set(id, (idCounts.get(id) || 0) + 1); + } + const sendableJobs = []; + const unconfirmableJobs = []; + const jobsById = new Map(); + for (const job of inputJobs) { + if (typeof job?.id === 'string' && job.id.trim().length > 0 && idCounts.get(job.id) === 1) { + sendableJobs.push(job); + jobsById.set(job.id, job); + } else { + unconfirmableJobs.push(job); + } + } + return { inputJobs, sendableJobs, unconfirmableJobs, jobsById }; +} + +function createAddJobsRequest(jobs) { + const analysis = analyzeAddJobsInput(jobs); + const cleanupPreparation = analysis.sendableJobs.length > 0 + ? prepareSourceCleanup(analysis.sendableJobs) + : { groups: [], rollbackStates: [] }; + return { + ...analysis, + cleanupPreparation, + payload: { + jobs: analysis.sendableJobs.map(serializeUploadJob), + sourceCleanupGroups: cleanupPreparation.groups + } + }; +} + +async function submitAddJobsRequest(request) { + if (request.sendableJobs.length === 0) return { added: 0 }; + return window.api.addJobsToBatch(request.payload); +} + +function resolveAddJobsOutcome(jobs, result) { + const { sendableJobs, unconfirmableJobs, jobsById } = analyzeAddJobsInput(jobs); const skippedEntries = []; const skippedIds = new Set(); const alreadyIds = new Set(); - let valid = jobsById.size === inputJobs.length; + let valid = true; for (const entry of Array.isArray(result?.skippedJobs) ? result.skippedJobs : []) { const id = entry?.jobId; if (!jobsById.has(id) || skippedIds.has(id)) { @@ -3812,17 +3849,17 @@ function resolveAddJobsOutcome(jobs, result) { } alreadyIds.add(id); } - const remainingJobs = inputJobs.filter(job => !skippedIds.has(job.id) && !alreadyIds.has(job.id)); + const remainingJobs = sendableJobs.filter(job => !skippedIds.has(job.id) && !alreadyIds.has(job.id)); const added = Number(result?.added); - const consistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length; + const confirmationConsistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length; return { - consistent, + consistent: unconfirmableJobs.length === 0 && confirmationConsistent, added: Number.isInteger(added) && added >= 0 ? added : 0, - addedJobs: consistent ? remainingJobs : [], + addedJobs: confirmationConsistent ? remainingJobs : [], alreadyJobs: [...alreadyIds].map(id => jobsById.get(id)), skippedEntries, skippedJobs: skippedEntries.map(entry => jobsById.get(entry.jobId)), - unconfirmedJobs: consistent ? [] : remainingJobs + unconfirmedJobs: [...unconfirmableJobs, ...(confirmationConsistent ? [] : remainingJobs)] }; } @@ -3963,7 +4000,8 @@ async function startSelectedUpload(explicitJobs) { } { const originalStates = captureUploadJobStates(addable); - const cleanupPreparation = prepareSourceCleanup(addable); + const addRequest = createAddJobsRequest(addable); + const cleanupPreparation = addRequest.cleanupPreparation; addable.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; @@ -3971,10 +4009,7 @@ async function startSelectedUpload(explicitJobs) { renderQueueTable(); let result = null; try { - result = await window.api.addJobsToBatch({ - jobs: addable.map(serializeUploadJob), - sourceCleanupGroups: cleanupPreparation.groups - }); + result = await submitAddJobsRequest(addRequest); } catch (err) { restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index c7530d9..ea6fd9e 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -253,7 +253,7 @@ contextBridge.exposeInMainWorld('api', { }, addJobsToBatch(payload) { folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]); - automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0]); + automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0, (payload?.jobs || []).map(job => job.id)]); if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError)); if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) { return Promise.resolve({ @@ -1179,6 +1179,137 @@ contextBridge.exposeInMainWorld('api', { } }; + const resolverMissingJob = makePauseRaceJob('resolver-missing.mkv'); + delete resolverMissingJob.id; + const collisionJobs = [ + { ...makePauseRaceJob('resolver-duplicate-a.mkv'), id: 'resolver-duplicate' }, + { ...makePauseRaceJob('resolver-duplicate-b.mkv'), id: 'resolver-duplicate' }, + resolverMissingJob, + { ...makePauseRaceJob('resolver-unique.mkv'), id: 'resolver-unique' } + ]; + const cleanCollisionOutcome = resolveAddJobsOutcome(collisionJobs, { added: 1 }); + const hostileCollisionOutcome = resolveAddJobsOutcome(collisionJobs, { + added: 1, + alreadyInBatchJobIds: ['resolver-duplicate'], + skippedJobs: [{ reason: 'Ungültig' }] + }); + const summarizeCollisionOutcome = outcome => ({ + consistent: outcome.consistent, + added: outcome.addedJobs.map(job => job.fileName), + already: outcome.alreadyJobs.map(job => job.fileName), + skipped: outcome.skippedJobs.map(job => job.fileName), + unconfirmed: outcome.unconfirmedJobs.map(job => job.fileName) + }); + const collisionResolver = { + clean: summarizeCollisionOutcome(cleanCollisionOutcome), + hostile: summarizeCollisionOutcome(hostileCollisionOutcome) + }; + + const applyCollisionCleanupFixture = (job, index) => { + job.sourceCleanupToken = 'collision-token-' + index; + job.sourceCleanupRequiredHosters = ['required-' + index]; + job.sourceCleanupCompletedHosters = ['completed-' + index]; + job.sourceCleanupFingerprint = { index, nested: ['before-' + index] }; + return job; + }; + const summarizeCollisionPath = async (jobs, result, before) => { + const probe = await window.api.getAutomationProbeState(); + const inject = probe.mutationCalls.find(call => call[0] === 'inject'); + return { + result, + sentIds: inject?.[2] || [], + statuses: jobs.map(job => job.status), + invalidRestored: jobs.slice(0, 3).map((job, index) => JSON.stringify(job) === before[index]) + }; + }; + + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + uploading = true; + const activeMissingJob = makePauseRaceJob('active-missing.mkv'); + delete activeMissingJob.id; + const activeCollisionJobs = [ + { ...makePauseRaceJob('active-duplicate-a.mkv'), id: 'active-duplicate' }, + { ...makePauseRaceJob('active-duplicate-b.mkv'), id: 'active-duplicate' }, + activeMissingJob, + { ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' } + ].map(applyCollisionCleanupFixture); + queueJobs = activeCollisionJobs; + rebuildJobIndex(); + const activeCollisionBefore = activeCollisionJobs.slice(0, 3).map(job => JSON.stringify(job)); + window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } }); + const activeCollisionResult = await startSelectedUpload(activeCollisionJobs); + const activeCollision = await summarizeCollisionPath(activeCollisionJobs, activeCollisionResult, activeCollisionBefore); + + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + selectedFiles = []; + uploading = true; + const manualCollisionFile = { path: 'C:\\collision\\manual.mkv', name: 'manual.mkv', size: 1 }; + _pendingFiles = [manualCollisionFile]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [manualCollisionFile] }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + const manualCollisionInputs = hosters.map(hoster => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.hosterModal = hoster; + input.checked = true; + return input; + }); + document.getElementById('hosterModalList').replaceChildren(...manualCollisionInputs); + const originalBuildQueuePreviewForCollision = buildQueuePreview; + let manualCollisionJobs = []; + let manualCollisionBefore = []; + buildQueuePreview = () => { + originalBuildQueuePreviewForCollision(); + manualCollisionJobs = queueJobs.filter(job => job.file === manualCollisionFile.path); + manualCollisionJobs[0].id = 'manual-duplicate'; + manualCollisionJobs[1].id = 'manual-duplicate'; + delete manualCollisionJobs[2].id; + manualCollisionJobs[3].id = 'manual-unique'; + manualCollisionJobs.forEach(applyCollisionCleanupFixture); + manualCollisionBefore = manualCollisionJobs.slice(0, 3).map(job => JSON.stringify(job)); + rebuildJobIndex(); + }; + window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } }); + const manualCollisionResult = await applyHosterSelection(); + buildQueuePreview = originalBuildQueuePreviewForCollision; + const manualCollision = await summarizeCollisionPath(manualCollisionJobs, manualCollisionResult, manualCollisionBefore); + + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + config.globalSettings.folderMonitor.hosters = hosters.slice(); + config.globalSettings.folderMonitor.autoStart = true; + selectedFiles = []; + uploading = true; + const automationCollisionFile = { path: 'C:\\collision\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 }; + const originalCreateAutomationPreviewJobForCollision = createAutomationPreviewJob; + const automationCollisionJobs = []; + const automationCollisionBefore = []; + createAutomationPreviewJob = (file, hoster) => { + const job = originalCreateAutomationPreviewJobForCollision(file, hoster); + const index = automationCollisionJobs.length; + if (index < 2) job.id = 'automation-duplicate'; + else if (index === 2) delete job.id; + else job.id = 'automation-unique'; + applyCollisionCleanupFixture(job, index); + automationCollisionJobs.push(job); + if (index < 3) automationCollisionBefore.push(JSON.stringify(job)); + return job; + }; + window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } }); + const automationCollisionEvaluation = await evaluateAutomationCandidates([automationCollisionFile], { dryRun: false, trigger: 'watcher' }); + const automationCollisionResult = await applyAutomationEvaluation(automationCollisionEvaluation); + createAutomationPreviewJob = originalCreateAutomationPreviewJobForCollision; + const automationCollision = await summarizeCollisionPath(automationCollisionJobs, { + ok: automationCollisionResult.ok, + error: automationCollisionResult.error, + admitted: automationCollisionResult.admittedFiles.map(file => file.name) + }, automationCollisionBefore); + uploading = false; + const collisionAdmission = { active: activeCollision, manual: manualCollision, automation: automationCollision }; + configureAtomicState(0); selectedFiles = []; config.globalSettings.folderMonitor.autoStart = true; @@ -1371,7 +1502,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; })()`; const onlineBackupBehaviorScript = `(async () => { const ids = { @@ -1950,6 +2081,42 @@ app.whenReady().then(async () => { } } }); + assert.deepEqual(result.automationPipeline.collisionResolver, { + clean: { + consistent: false, + added: ['resolver-unique.mkv'], + already: [], + skipped: [], + unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv'] + }, + hostile: { + consistent: false, + added: [], + already: [], + skipped: [], + unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv', 'resolver-unique.mkv'] + } + }); + assert.deepEqual(result.automationPipeline.collisionAdmission, { + active: { + result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' }, + sentIds: ['active-unique'], + statuses: ['preview', 'preview', 'preview', 'queued'], + invalidRestored: [true, true, true] + }, + manual: { + result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' }, + sentIds: ['manual-unique'], + statuses: ['preview', 'preview', 'preview', 'queued'], + invalidRestored: [true, true, true] + }, + automation: { + result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', admitted: [] }, + sentIds: ['automation-unique'], + statuses: ['preview', 'preview', 'preview', 'queued'], + invalidRestored: [true, true, true] + } + }); assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, { result: { ok: false,