feat: add automation control policies
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
normalizeAutomationSettings,
|
||||
countAutomaticQueueJobs,
|
||||
planAtomicAdmissions,
|
||||
rollDailyTelemetry,
|
||||
applyTelemetryDelta,
|
||||
deriveAutomationState,
|
||||
classifyProcessedCandidates
|
||||
} = require('../lib/automation-control');
|
||||
|
||||
test('automation defaults use 15000 jobs and a five minute reconciliation interval', () => {
|
||||
assert.deepEqual(normalizeAutomationSettings({}), {
|
||||
queueLimitJobs: 15000,
|
||||
reconcileIntervalMinutes: 5,
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
});
|
||||
|
||||
test('automation settings normalize invalid limits intervals and pause timestamps', () => {
|
||||
assert.deepEqual(normalizeAutomationSettings({
|
||||
queueLimitJobs: -1,
|
||||
reconcileIntervalMinutes: 10,
|
||||
paused: true,
|
||||
pausedAt: '1700'
|
||||
}), {
|
||||
queueLimitJobs: 15000,
|
||||
reconcileIntervalMinutes: 5,
|
||||
paused: true,
|
||||
pausedAt: 1700
|
||||
});
|
||||
assert.deepEqual(normalizeAutomationSettings({
|
||||
queueLimitJobs: 42.9,
|
||||
reconcileIntervalMinutes: '15',
|
||||
pausedAt: 1700
|
||||
}), {
|
||||
queueLimitJobs: 42,
|
||||
reconcileIntervalMinutes: 15,
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
assert.deepEqual(normalizeAutomationSettings(null), {
|
||||
queueLimitJobs: 15000,
|
||||
reconcileIntervalMinutes: 5,
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
});
|
||||
|
||||
test('capacity counts only executable and running queue jobs', () => {
|
||||
const statuses = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
|
||||
assert.equal(countAutomaticQueueJobs(statuses.map((status, index) => ({ id: String(index), status }))), 5);
|
||||
assert.equal(countAutomaticQueueJobs(null), 0);
|
||||
});
|
||||
|
||||
test('admission keeps every eligible host job for a file atomic', () => {
|
||||
const plan = planAtomicAdmissions({
|
||||
candidates: [
|
||||
{ path: 'C:\\watch\\a.mkv', mtimeMs: 1000, eligibleJobCount: 4 },
|
||||
{ path: 'C:\\watch\\b.mkv', mtimeMs: 2000, eligibleJobCount: 2 }
|
||||
],
|
||||
currentJobCount: 14997,
|
||||
queueLimitJobs: 15000
|
||||
});
|
||||
assert.deepEqual(plan.admittedPaths, ['C:\\watch\\b.mkv']);
|
||||
assert.deepEqual(plan.deferredPaths, ['C:\\watch\\a.mkv']);
|
||||
assert.equal(plan.plannedJobs, 2);
|
||||
assert.equal(plan.availableSlots, 1);
|
||||
});
|
||||
|
||||
test('admission uses stable mtime and path ordering without mutating candidates', () => {
|
||||
const candidates = [
|
||||
{ path: 'b', mtimeMs: 10, eligibleJobCount: 1 },
|
||||
{ path: 'c', mtimeMs: 5, eligibleJobCount: 1 },
|
||||
{ path: 'a', mtimeMs: 10, eligibleJobCount: 1 }
|
||||
];
|
||||
const snapshot = structuredClone(candidates);
|
||||
const plan = planAtomicAdmissions({ candidates, currentJobCount: 0, queueLimitJobs: 2 });
|
||||
assert.deepEqual(plan.admittedPaths, ['c', 'a']);
|
||||
assert.deepEqual(plan.deferredPaths, ['b']);
|
||||
assert.deepEqual(candidates, snapshot);
|
||||
});
|
||||
|
||||
test('unlimited admission accepts all eligible files', () => {
|
||||
const plan = planAtomicAdmissions({
|
||||
candidates: [{ path: 'a', mtimeMs: 1, eligibleJobCount: 20000 }],
|
||||
currentJobCount: 50000,
|
||||
queueLimitJobs: 0
|
||||
});
|
||||
assert.deepEqual(plan.admittedPaths, ['a']);
|
||||
assert.deepEqual(plan.deferredPaths, []);
|
||||
assert.equal(plan.availableSlots, null);
|
||||
});
|
||||
|
||||
test('admission tolerates malformed candidates and numeric inputs', () => {
|
||||
assert.deepEqual(planAtomicAdmissions({ candidates: null, currentJobCount: -4, queueLimitJobs: '3' }), {
|
||||
admittedPaths: [],
|
||||
deferredPaths: [],
|
||||
currentJobCount: 0,
|
||||
plannedJobs: 0,
|
||||
availableSlots: 3
|
||||
});
|
||||
assert.deepEqual(planAtomicAdmissions(null), {
|
||||
admittedPaths: [],
|
||||
deferredPaths: [],
|
||||
currentJobCount: 0,
|
||||
plannedJobs: 0,
|
||||
availableSlots: 15000
|
||||
});
|
||||
});
|
||||
|
||||
test('daily telemetry resets atomically on the local calendar day boundary', () => {
|
||||
const before = { dateKey: '2026-08-25', detected: 8, queued: 4, skipped: 2, deferred: 1 };
|
||||
const now = new Date(2026, 7, 26, 0, 0, 1).getTime();
|
||||
assert.deepEqual(rollDailyTelemetry(before, now), {
|
||||
dateKey: '2026-08-26',
|
||||
detected: 0,
|
||||
queued: 0,
|
||||
skipped: 0,
|
||||
deferred: 0,
|
||||
lastDetectedName: '',
|
||||
lastDetectedAt: null,
|
||||
lastError: '',
|
||||
lastErrorAt: null
|
||||
});
|
||||
});
|
||||
|
||||
test('daily telemetry normalizes malformed same-day counters without mutating input', () => {
|
||||
const now = new Date(2026, 7, 26, 12, 0, 0).getTime();
|
||||
const telemetry = { dateKey: '2026-08-26', detected: -2, queued: '3', lastError: 'network' };
|
||||
const snapshot = structuredClone(telemetry);
|
||||
assert.deepEqual(rollDailyTelemetry(telemetry, now), {
|
||||
dateKey: '2026-08-26',
|
||||
detected: 0,
|
||||
queued: 3,
|
||||
skipped: 0,
|
||||
deferred: 0,
|
||||
lastDetectedName: '',
|
||||
lastDetectedAt: null,
|
||||
lastError: 'network',
|
||||
lastErrorAt: null
|
||||
});
|
||||
assert.deepEqual(telemetry, snapshot);
|
||||
assert.equal(rollDailyTelemetry(null, now).dateKey, '2026-08-26');
|
||||
});
|
||||
|
||||
test('telemetry deltas increment counters and update event details immutably', () => {
|
||||
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
|
||||
const telemetry = {
|
||||
dateKey: '2026-08-26',
|
||||
detected: 1,
|
||||
queued: 2,
|
||||
skipped: 3,
|
||||
deferred: 4,
|
||||
lastDetectedName: '',
|
||||
lastDetectedAt: null,
|
||||
lastError: 'old',
|
||||
lastErrorAt: 10
|
||||
};
|
||||
const result = applyTelemetryDelta(telemetry, {
|
||||
detected: 2,
|
||||
queued: 3,
|
||||
skipped: -1,
|
||||
deferred: '2',
|
||||
lastDetectedName: 'episode.mkv',
|
||||
lastError: ''
|
||||
}, now);
|
||||
assert.deepEqual(result, {
|
||||
dateKey: '2026-08-26',
|
||||
detected: 3,
|
||||
queued: 5,
|
||||
skipped: 3,
|
||||
deferred: 6,
|
||||
lastDetectedName: 'episode.mkv',
|
||||
lastDetectedAt: now,
|
||||
lastError: '',
|
||||
lastErrorAt: null
|
||||
});
|
||||
assert.equal(telemetry.detected, 1);
|
||||
assert.equal(telemetry.lastError, 'old');
|
||||
});
|
||||
|
||||
test('pause has higher display priority than disconnect error and queue limit', () => {
|
||||
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
|
||||
});
|
||||
|
||||
test('automation state follows inactive disconnected error queue-limited and active priority', () => {
|
||||
assert.equal(deriveAutomationState({ enabled: false, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'inactive');
|
||||
assert.equal(deriveAutomationState({ enabled: true, folderPath: '', reachable: false, error: 'x', queueLimited: true }), 'inactive');
|
||||
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'disconnected');
|
||||
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, error: 'x', queueLimited: true }), 'error');
|
||||
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, queueLimited: true }), 'queue-limited');
|
||||
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true }), 'active');
|
||||
assert.equal(deriveAutomationState(null), 'inactive');
|
||||
});
|
||||
|
||||
test('exact queue and history paths mark candidates processed case-insensitively', () => {
|
||||
const result = classifyProcessedCandidates({
|
||||
candidates: [
|
||||
{ path: 'C:\\Watch\\queue.mkv' },
|
||||
{ path: 'C:\\Watch\\history.mkv' },
|
||||
{ path: 'C:\\Watch\\new.mkv' }
|
||||
],
|
||||
queuePaths: ['c:/watch/QUEUE.mkv'],
|
||||
historyRows: [{ file: 'c:/watch/HISTORY.mkv' }],
|
||||
uploadLogRows: []
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
processedPaths: ['C:\\Watch\\queue.mkv', 'C:\\Watch\\history.mkv'],
|
||||
ambiguousPaths: [],
|
||||
unprocessedPaths: ['C:\\Watch\\new.mkv']
|
||||
});
|
||||
});
|
||||
|
||||
test('unique basename evidence marks one candidate processed', () => {
|
||||
const result = classifyProcessedCandidates({
|
||||
candidates: [{ path: 'C:\\watch\\unique.mkv' }, { path: 'C:\\watch\\other.mkv' }],
|
||||
uploadLogRows: [{ filename: 'UNIQUE.MKV' }]
|
||||
});
|
||||
assert.deepEqual(result.processedPaths, ['C:\\watch\\unique.mkv']);
|
||||
assert.deepEqual(result.unprocessedPaths, ['C:\\watch\\other.mkv']);
|
||||
});
|
||||
|
||||
test('ambiguous same-name log evidence never marks a candidate processed', () => {
|
||||
const result = classifyProcessedCandidates({
|
||||
candidates: [
|
||||
{ path: 'C:\\one\\episode.mkv', name: 'episode.mkv' },
|
||||
{ path: 'D:\\two\\episode.mkv', name: 'episode.mkv' }
|
||||
],
|
||||
queuePaths: [],
|
||||
historyRows: [],
|
||||
uploadLogRows: [{ fileName: 'episode.mkv', hoster: 'doodstream.com' }]
|
||||
});
|
||||
assert.deepEqual(result.processedPaths, []);
|
||||
assert.deepEqual(result.ambiguousPaths.sort(), ['C:\\one\\episode.mkv', 'D:\\two\\episode.mkv'].sort());
|
||||
});
|
||||
|
||||
test('processed classification tolerates malformed collections and does not mutate candidates', () => {
|
||||
const candidates = [{ path: 'C:\\watch\\episode.mkv', name: 'episode.mkv' }];
|
||||
const snapshot = structuredClone(candidates);
|
||||
assert.deepEqual(classifyProcessedCandidates({
|
||||
candidates,
|
||||
queuePaths: null,
|
||||
historyRows: null,
|
||||
uploadLogRows: null
|
||||
}), {
|
||||
processedPaths: [],
|
||||
ambiguousPaths: [],
|
||||
unprocessedPaths: ['C:\\watch\\episode.mkv']
|
||||
});
|
||||
assert.deepEqual(candidates, snapshot);
|
||||
assert.deepEqual(classifyProcessedCandidates(null), {
|
||||
processedPaths: [],
|
||||
ambiguousPaths: [],
|
||||
unprocessedPaths: []
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user