test: verify automation control center

Cover real folder reconciliation from startup through queue-capacity recovery and reconnect.

Stress the 15,000-job policy, parallel event deduplication, Infinity fallback, restart-persistent pause, the delayed batch gate, and repeated finish behavior without expanding the rendered queue.

Document status, read-only scans, atomic admission, reconciliation, reconnect, pause and current-data processing semantics in English.
This commit is contained in:
Sucukdeluxe
2026-08-26 14:01:10 +02:00
parent 325a20d3fb
commit 362d1dd2cb
4 changed files with 440 additions and 49 deletions
+90 -13
View File
@@ -5,6 +5,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const FolderMonitor = require('../lib/folder-monitor');
const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control');
function createWatcherHarness() {
const calls = [];
@@ -210,6 +211,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () =>
assert.equal(monitor.status().paused, false);
});
test('repeated pause emits one status change and keeps reconciliation stopped', async () => {
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCountBeforePause = statusEvents.length;
await monitor.pause();
await monitor.pause();
await runInterval();
assert.equal(statusEvents.length, statusCountBeforePause + 1);
assert.equal(statusEvents.at(-1).paused, true);
assert.equal(scanCalls(), 0);
});
test('stop emits an immutable non-running status snapshot', () => {
const { monitor } = createWatcherHarness();
const statuses = [];
@@ -536,26 +551,88 @@ test('interval callback contains unexpected scan rejection', async () => {
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
});
test('real temporary folder scan survives disconnect and reconnect', async () => {
test('real temporary folder converges through startup interval capacity recovery and one reconnect', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
const detached = `${root}-detached`;
const timers = createManualTimers();
const queuedPaths = new Set();
const admissions = [];
let currentJobCount = 14998;
try {
fs.mkdirSync(path.join(root, 'nested'));
fs.writeFileSync(path.join(root, 'a.mkv'), Buffer.from('a'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('b'));
fs.writeFileSync(path.join(root, 'nested', 'c.mkv'), Buffer.from('c'));
const monitor = new FolderMonitor();
monitor.start({ folderPath: root, recursive: true, extensions: 'mkv', filterMode: 'include', reconcileIntervalMinutes: 5 });
const first = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.deepEqual(first.files.map((file) => file.name).sort(), ['a.mkv', 'c.mkv']);
const fourJobPath = path.join(root, 'four-jobs.mkv');
const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv');
fs.writeFileSync(fourJobPath, Buffer.from('four'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored'));
fs.writeFileSync(twoJobPath, Buffer.from('two'));
fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded'));
fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z'));
fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.on('new-files', (paths) => {
const descriptors = paths.map(filePath => ({
path: filePath,
name: path.basename(filePath),
mtimeMs: fs.statSync(filePath).mtimeMs
}));
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
const unprocessed = new Set(processed.unprocessedPaths);
const candidates = descriptors
.filter(file => unprocessed.has(file.path))
.map(file => ({ ...file, eligibleJobCount: file.name === 'four-jobs.mkv' ? 4 : 2 }));
const plan = planAtomicAdmissions({ candidates, currentJobCount, queueLimitJobs: 15000 });
for (const filePath of plan.admittedPaths) queuedPaths.add(filePath);
currentJobCount += plan.plannedJobs;
admissions.push({
trigger: monitor.status().lastScanTrigger,
admittedPaths: plan.admittedPaths,
deferredPaths: plan.deferredPaths,
currentJobCount
});
});
monitor.start({
folderPath: root,
recursive: true,
extensions: 'mkv',
filterMode: 'include',
skipDuplicates: false,
reconcileIntervalMinutes: 5
});
const first = await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.deepEqual(first.files.map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']);
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
assert.deepEqual(admissions[0], {
trigger: 'startup',
admittedPaths: [twoJobPath],
deferredPaths: [fourJobPath],
currentJobCount: 15000
});
currentJobCount = 14996;
await timers.runInterval();
assert.deepEqual(admissions[1], {
trigger: 'interval',
admittedPaths: [fourJobPath],
deferredPaths: [],
currentJobCount: 15000
});
fs.renameSync(root, detached);
const disconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.equal(disconnected.reachable, false);
await timers.runInterval();
assert.equal(monitor.status().reachable, false);
assert.equal(admissions.length, 2);
fs.renameSync(detached, root);
const reconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.equal(reconnected.reachable, true);
assert.equal(reconnected.reconnected, true);
await timers.runInterval();
assert.equal(monitor.status().reachable, true);
assert.equal(admissions.at(-1).trigger, 'reconnect');
assert.deepEqual(admissions.at(-1).admittedPaths, []);
await timers.runInterval();
assert.equal(admissions.filter(entry => entry.trigger === 'reconnect').length, 1);
assert.equal(admissions.at(-1).trigger, 'interval');
assert.deepEqual([...queuedPaths].sort(), [fourJobPath, twoJobPath].sort());
await monitor.pause();
} finally {
if (fs.existsSync(detached)) fs.renameSync(detached, root);