feat: add automation control policies
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
(function initAutomationControl(root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.AutomationControl = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis, function createAutomationControl() {
|
||||||
|
const capacityStatuses = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
|
||||||
|
const allowedIntervals = new Set([1, 5, 15, 30, 60]);
|
||||||
|
|
||||||
|
function asObject(value) {
|
||||||
|
return value && typeof value === 'object' ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutomationSettings(value = {}) {
|
||||||
|
const settings = asObject(value);
|
||||||
|
const rawLimit = Number(settings.queueLimitJobs);
|
||||||
|
const queueLimitJobs = Number.isFinite(rawLimit) && rawLimit >= 0 ? Math.floor(rawLimit) : 15000;
|
||||||
|
const rawInterval = Number(settings.reconcileIntervalMinutes);
|
||||||
|
return {
|
||||||
|
queueLimitJobs,
|
||||||
|
reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5,
|
||||||
|
paused: settings.paused === true,
|
||||||
|
pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countAutomaticQueueJobs(queueJobs) {
|
||||||
|
return asArray(queueJobs).reduce((count, job) => count + (capacityStatuses.has(job?.status) ? 1 : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planAtomicAdmissions(input = {}) {
|
||||||
|
const value = asObject(input);
|
||||||
|
const ordered = [...asArray(value.candidates)].sort((left, right) =>
|
||||||
|
(Number(left?.mtimeMs) || 0) - (Number(right?.mtimeMs) || 0)
|
||||||
|
|| String(left?.path).localeCompare(String(right?.path))
|
||||||
|
);
|
||||||
|
const rawCurrentJobCount = Number(value.currentJobCount);
|
||||||
|
const currentJobCount = Number.isFinite(rawCurrentJobCount) ? Math.max(0, Math.floor(rawCurrentJobCount)) : 0;
|
||||||
|
const rawQueueLimitJobs = value.queueLimitJobs === undefined ? 15000 : Number(value.queueLimitJobs);
|
||||||
|
const queueLimitJobs = Number.isFinite(rawQueueLimitJobs) && rawQueueLimitJobs >= 0 ? Math.floor(rawQueueLimitJobs) : 15000;
|
||||||
|
let available = queueLimitJobs === 0 ? Number.POSITIVE_INFINITY : Math.max(0, queueLimitJobs - currentJobCount);
|
||||||
|
const admittedPaths = [];
|
||||||
|
const deferredPaths = [];
|
||||||
|
let plannedJobs = 0;
|
||||||
|
for (const candidate of ordered) {
|
||||||
|
const required = Math.max(0, Math.floor(Number(candidate?.eligibleJobCount) || 0));
|
||||||
|
if (required > 0 && required <= available) {
|
||||||
|
admittedPaths.push(candidate?.path);
|
||||||
|
available -= required;
|
||||||
|
plannedJobs += required;
|
||||||
|
} else if (required > 0) {
|
||||||
|
deferredPaths.push(candidate?.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
admittedPaths,
|
||||||
|
deferredPaths,
|
||||||
|
currentJobCount,
|
||||||
|
plannedJobs,
|
||||||
|
availableSlots: Number.isFinite(available) ? available : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateKey(nowMs) {
|
||||||
|
const date = new Date(nowMs);
|
||||||
|
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTelemetry(dateKey) {
|
||||||
|
return {
|
||||||
|
dateKey,
|
||||||
|
detected: 0,
|
||||||
|
queued: 0,
|
||||||
|
skipped: 0,
|
||||||
|
deferred: 0,
|
||||||
|
lastDetectedName: '',
|
||||||
|
lastDetectedAt: null,
|
||||||
|
lastError: '',
|
||||||
|
lastErrorAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollDailyTelemetry(value = {}, nowMs = Date.now()) {
|
||||||
|
const telemetry = asObject(value);
|
||||||
|
const dateKey = localDateKey(nowMs);
|
||||||
|
if (telemetry.dateKey !== dateKey) return emptyTelemetry(dateKey);
|
||||||
|
return {
|
||||||
|
...emptyTelemetry(dateKey),
|
||||||
|
...telemetry,
|
||||||
|
dateKey,
|
||||||
|
detected: Math.max(0, Number(telemetry.detected) || 0),
|
||||||
|
queued: Math.max(0, Number(telemetry.queued) || 0),
|
||||||
|
skipped: Math.max(0, Number(telemetry.skipped) || 0),
|
||||||
|
deferred: Math.max(0, Number(telemetry.deferred) || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTelemetryDelta(value, delta = {}, nowMs = Date.now()) {
|
||||||
|
const changes = asObject(delta);
|
||||||
|
const next = rollDailyTelemetry(value, nowMs);
|
||||||
|
for (const key of ['detected', 'queued', 'skipped', 'deferred']) {
|
||||||
|
next[key] += Math.max(0, Number(changes[key]) || 0);
|
||||||
|
}
|
||||||
|
if (changes.lastDetectedName) {
|
||||||
|
next.lastDetectedName = String(changes.lastDetectedName);
|
||||||
|
next.lastDetectedAt = nowMs;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(changes, 'lastError')) {
|
||||||
|
next.lastError = String(changes.lastError || '');
|
||||||
|
next.lastErrorAt = next.lastError ? nowMs : null;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAutomationState(value = {}) {
|
||||||
|
const state = asObject(value);
|
||||||
|
if (state.paused === true) return 'paused';
|
||||||
|
if (state.enabled !== true || !state.folderPath) return 'inactive';
|
||||||
|
if (state.reachable === false) return 'disconnected';
|
||||||
|
if (state.error) return 'error';
|
||||||
|
if (state.queueLimited === true) return 'queue-limited';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value) {
|
||||||
|
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(value) {
|
||||||
|
return String(value || '').split(/[\\/]/).pop().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyProcessedCandidates(input = {}) {
|
||||||
|
const value = asObject(input);
|
||||||
|
const candidates = asArray(value.candidates);
|
||||||
|
const historyRows = asArray(value.historyRows);
|
||||||
|
const uploadLogRows = asArray(value.uploadLogRows);
|
||||||
|
const exactPaths = new Set(asArray(value.queuePaths).map(normalizePath).filter(Boolean));
|
||||||
|
for (const row of historyRows) {
|
||||||
|
const exact = normalizePath(row?.path || row?.file);
|
||||||
|
if (exact) exactPaths.add(exact);
|
||||||
|
}
|
||||||
|
const evidenceNames = new Set();
|
||||||
|
for (const row of [...historyRows, ...uploadLogRows]) {
|
||||||
|
const name = baseName(row?.fileName || row?.filename || row?.name || row?.path || row?.file);
|
||||||
|
if (name) evidenceNames.add(name);
|
||||||
|
}
|
||||||
|
const nameCounts = new Map();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const name = baseName(candidate?.name || candidate?.path);
|
||||||
|
nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
|
||||||
|
}
|
||||||
|
const processedPaths = [];
|
||||||
|
const ambiguousPaths = [];
|
||||||
|
const unprocessedPaths = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const exact = normalizePath(candidate?.path);
|
||||||
|
const name = baseName(candidate?.name || candidate?.path);
|
||||||
|
if ((exact && exactPaths.has(exact)) || (evidenceNames.has(name) && nameCounts.get(name) === 1)) processedPaths.push(candidate?.path);
|
||||||
|
else if (evidenceNames.has(name) && nameCounts.get(name) > 1) ambiguousPaths.push(candidate?.path);
|
||||||
|
else unprocessedPaths.push(candidate?.path);
|
||||||
|
}
|
||||||
|
return { processedPaths, ambiguousPaths, unprocessedPaths };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizeAutomationSettings,
|
||||||
|
countAutomaticQueueJobs,
|
||||||
|
planAtomicAdmissions,
|
||||||
|
rollDailyTelemetry,
|
||||||
|
applyTelemetryDelta,
|
||||||
|
deriveAutomationState,
|
||||||
|
classifyProcessedCandidates
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -713,6 +713,7 @@
|
|||||||
<script src="../lib/serialized-runner.js"></script>
|
<script src="../lib/serialized-runner.js"></script>
|
||||||
<script src="../lib/speed-history.js"></script>
|
<script src="../lib/speed-history.js"></script>
|
||||||
<script src="../lib/import-preflight.js"></script>
|
<script src="../lib/import-preflight.js"></script>
|
||||||
|
<script src="../lib/automation-control.js"></script>
|
||||||
<script src="account-submit.js"></script>
|
<script src="account-submit.js"></script>
|
||||||
<script src="account-status.js"></script>
|
<script src="account-status.js"></script>
|
||||||
<script src="history-status.js"></script>
|
<script src="history-status.js"></script>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const sourceFiles = [
|
|||||||
`${publicActionsDir}/workflows/ci.yml`,
|
`${publicActionsDir}/workflows/ci.yml`,
|
||||||
'lib/account-auth.js',
|
'lib/account-auth.js',
|
||||||
'lib/account-rotation.js',
|
'lib/account-rotation.js',
|
||||||
|
'lib/automation-control.js',
|
||||||
'lib/backup-crypto.js',
|
'lib/backup-crypto.js',
|
||||||
'lib/clouddrop-upload.js',
|
'lib/clouddrop-upload.js',
|
||||||
'lib/coalesced-set.js',
|
'lib/coalesced-set.js',
|
||||||
@@ -96,6 +97,7 @@ const sourceFiles = [
|
|||||||
'tests/account-auth.test.js',
|
'tests/account-auth.test.js',
|
||||||
'tests/account-rotation.test.js',
|
'tests/account-rotation.test.js',
|
||||||
'tests/account-status.test.js',
|
'tests/account-status.test.js',
|
||||||
|
'tests/automation-control.test.js',
|
||||||
'tests/auto-resume.test.js',
|
'tests/auto-resume.test.js',
|
||||||
'tests/backup-crypto.test.js',
|
'tests/backup-crypto.test.js',
|
||||||
'tests/byse-reject-recovery.test.js',
|
'tests/byse-reject-recovery.test.js',
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ test('public release verifier accepts only the exact source manifest and target
|
|||||||
assert.equal(baseline.status, 0, baseline.stderr);
|
assert.equal(baseline.status, 0, baseline.stderr);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
baseline.stdout,
|
baseline.stdout,
|
||||||
`public-release-source-ok files=157 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
`public-release-source-ok files=159 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||||
|
|||||||
Reference in New Issue
Block a user