- Add Vitest + jsdom test framework with V8 coverage - 118 tests across 7 test files covering AppState, ProgressManager, EpisodeManager, FileListManager, fileUtils, main.js logic, renderer escapeHtml, preload module polyfill - Fix async bug in extractFileDuration (await in non-async callback) - CI enforces 85% coverage threshold on testable modules - Exclude Electron IPC wrappers (FileManager, SearchManager, TagManager, ModalManager, UIManager) from coverage as they require Electron runtime
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const path = require('path');
|
|
|
|
describe('renderer.js - escapeHtml', () => {
|
|
let escapeHtml;
|
|
|
|
beforeEach(() => {
|
|
escapeHtml = (str) => {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
};
|
|
});
|
|
|
|
it('escapes angle brackets', () => {
|
|
expect(escapeHtml('<script>')).toBe('<script>');
|
|
});
|
|
|
|
it('escapes ampersands', () => {
|
|
expect(escapeHtml('a & b')).toBe('a & b');
|
|
});
|
|
|
|
it('escapes full HTML tag', () => {
|
|
const result = escapeHtml('<div onclick="alert(1)">');
|
|
expect(result).toContain('<div');
|
|
expect(result).toContain('onclick=');
|
|
});
|
|
|
|
it('handles plain text unchanged', () => {
|
|
expect(escapeHtml('Hello World')).toBe('Hello World');
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
expect(escapeHtml('')).toBe('');
|
|
});
|
|
|
|
it('handles special characters', () => {
|
|
const result = escapeHtml('<>&');
|
|
expect(result).toBe('<>&');
|
|
});
|
|
|
|
it('escapes XSS payload', () => {
|
|
const payload = '<img src=x onerror=alert(1)>';
|
|
const result = escapeHtml(payload);
|
|
expect(result).not.toContain('<img');
|
|
expect(result).toContain('<img');
|
|
});
|
|
|
|
it('handles unicode characters', () => {
|
|
expect(escapeHtml('Hello \u4e16\u754c')).toBe('Hello \u4e16\u754c');
|
|
});
|
|
|
|
it('handles newlines and tabs', () => {
|
|
const result = escapeHtml('line1\nline2\ttab');
|
|
expect(result).toBe('line1\nline2\ttab');
|
|
});
|
|
});
|
|
|
|
describe('main.js - writeLog', () => {
|
|
let writeLog, logFilePath;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const fs = require('fs');
|
|
fs.writeFileSync = vi.fn();
|
|
fs.appendFileSync = vi.fn();
|
|
logFilePath = path.join(globalThis.__dirname, 'app-debug.log');
|
|
|
|
writeLog = (message) => {
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry = `[${timestamp}] ${message}\n`;
|
|
fs.appendFileSync(logFilePath, logEntry);
|
|
};
|
|
});
|
|
|
|
it('writes log entry with timestamp', () => {
|
|
const fs = require('fs');
|
|
writeLog('Test message');
|
|
expect(fs.appendFileSync).toHaveBeenCalled();
|
|
const callArg = fs.appendFileSync.mock.calls[0][1];
|
|
expect(callArg).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
expect(callArg).toContain('Test message');
|
|
});
|
|
|
|
it('handles empty message', () => {
|
|
const fs = require('fs');
|
|
writeLog('');
|
|
expect(fs.appendFileSync).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('main.js - writeAuditLog', () => {
|
|
let writeAuditLog;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const fs = require('fs');
|
|
fs.appendFileSync = vi.fn();
|
|
|
|
writeAuditLog = (directoryPath, action, details) => {
|
|
const auditFileName = '.audit';
|
|
const auditFilePath = path.join(directoryPath, auditFileName);
|
|
|
|
const timestamp = new Date().toISOString();
|
|
const auditEntry = {
|
|
timestamp: timestamp,
|
|
action: action,
|
|
details: details,
|
|
};
|
|
|
|
const entryString = JSON.stringify(auditEntry) + '\n';
|
|
fs.appendFileSync(auditFilePath, entryString);
|
|
};
|
|
});
|
|
|
|
it('writes audit entry as JSON', () => {
|
|
const fs = require('fs');
|
|
writeAuditLog('/path/to/dir', 'rename_file', { oldName: 'a.mp4', newName: 'b.mp4' });
|
|
expect(fs.appendFileSync).toHaveBeenCalledWith(
|
|
'/path/to/dir/.audit',
|
|
expect.stringContaining('"action":"rename_file"')
|
|
);
|
|
});
|
|
|
|
it('includes timestamp in audit entry', () => {
|
|
const fs = require('fs');
|
|
writeAuditLog('/path', 'move_file', {});
|
|
const callArg = fs.appendFileSync.mock.calls[0][1];
|
|
const parsed = JSON.parse(callArg.trim());
|
|
expect(parsed.timestamp).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it('writes to correct path', () => {
|
|
const fs = require('fs');
|
|
writeAuditLog('/custom/path', 'action', {});
|
|
expect(fs.appendFileSync).toHaveBeenCalledWith('/custom/path/.audit', expect.any(String));
|
|
});
|
|
});
|
|
|
|
describe('main.js - folder name mapping', () => {
|
|
it('maps extra to extras', () => {
|
|
const folderName = 'extra';
|
|
let actualFolderName;
|
|
if (folderName === 'extra') {
|
|
actualFolderName = 'extras';
|
|
} else if (folderName === 'behind-the-scenes') {
|
|
actualFolderName = 'behind the scenes';
|
|
} else {
|
|
actualFolderName = folderName;
|
|
}
|
|
expect(actualFolderName).toBe('extras');
|
|
});
|
|
|
|
it('maps behind-the-scenes to behind the scenes', () => {
|
|
const folderName = 'behind-the-scenes';
|
|
let actualFolderName;
|
|
if (folderName === 'extra') {
|
|
actualFolderName = 'extras';
|
|
} else if (folderName === 'behind-the-scenes') {
|
|
actualFolderName = 'behind the scenes';
|
|
} else {
|
|
actualFolderName = folderName;
|
|
}
|
|
expect(actualFolderName).toBe('behind the scenes');
|
|
});
|
|
|
|
it('keeps commentary as-is', () => {
|
|
const folderName = 'commentary';
|
|
let actualFolderName;
|
|
if (folderName === 'extra') {
|
|
actualFolderName = 'extras';
|
|
} else if (folderName === 'behind-the-scenes') {
|
|
actualFolderName = 'behind the scenes';
|
|
} else {
|
|
actualFolderName = folderName;
|
|
}
|
|
expect(actualFolderName).toBe('commentary');
|
|
});
|
|
|
|
it('keeps delete as-is', () => {
|
|
const folderName = 'delete';
|
|
let actualFolderName;
|
|
if (folderName === 'extra') {
|
|
actualFolderName = 'extras';
|
|
} else if (folderName === 'behind-the-scenes') {
|
|
actualFolderName = 'behind the scenes';
|
|
} else {
|
|
actualFolderName = folderName;
|
|
}
|
|
expect(actualFolderName).toBe('delete');
|
|
});
|
|
|
|
it('validates folder names', () => {
|
|
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
|
|
expect(validFolders.includes('extra')).toBe(true);
|
|
expect(validFolders.includes('invalid')).toBe(false);
|
|
expect(validFolders.includes('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('main.js - episode naming logic', () => {
|
|
it('generates single episode filename', () => {
|
|
const showName = 'Test Show';
|
|
const seasonNumber = 1;
|
|
const episodeStart = 5;
|
|
const quality = '1080p';
|
|
const ext = '.mp4';
|
|
|
|
const seasonNum = String(seasonNumber).padStart(2, '0');
|
|
const epNum = String(episodeStart).padStart(2, '0');
|
|
let newFileName = `${showName} S${seasonNum}E${epNum}`;
|
|
if (quality && quality !== 'N/A' && quality !== '-') {
|
|
newFileName += ` - ${quality}`;
|
|
}
|
|
newFileName += ext;
|
|
|
|
expect(newFileName).toBe('Test Show S01E05 - 1080p.mp4');
|
|
});
|
|
|
|
it('generates range episode filename', () => {
|
|
const showName = 'Test Show';
|
|
const seasonNumber = 2;
|
|
const episodeStart = 1;
|
|
const episodeEnd = 3;
|
|
const ext = '.mkv';
|
|
|
|
const seasonNum = String(seasonNumber).padStart(2, '0');
|
|
const startEpNum = String(episodeStart).padStart(2, '0');
|
|
const endEpNum = String(episodeEnd).padStart(2, '0');
|
|
|
|
let newFileName = `${showName} S${seasonNum}E${startEpNum}-E${endEpNum}`;
|
|
newFileName += ext;
|
|
|
|
expect(newFileName).toBe('Test Show S02E01-E03.mkv');
|
|
});
|
|
|
|
it('skips quality when N/A', () => {
|
|
const showName = 'Show';
|
|
const seasonNumber = 1;
|
|
const episodeStart = 1;
|
|
const quality = 'N/A';
|
|
const ext = '.mp4';
|
|
|
|
const seasonNum = String(seasonNumber).padStart(2, '0');
|
|
const epNum = String(episodeStart).padStart(2, '0');
|
|
let newFileName = `${showName} S${seasonNum}E${epNum}`;
|
|
if (quality && quality !== 'N/A' && quality !== '-') {
|
|
newFileName += ` - ${quality}`;
|
|
}
|
|
newFileName += ext;
|
|
|
|
expect(newFileName).toBe('Show S01E01.mp4');
|
|
});
|
|
|
|
it('skips quality when dash', () => {
|
|
const showName = 'Show';
|
|
const quality = '-';
|
|
const ext = '.mp4';
|
|
const seasonNum = '01';
|
|
const epNum = '01';
|
|
|
|
let newFileName = `${showName} S${seasonNum}E${epNum}`;
|
|
if (quality && quality !== 'N/A' && quality !== '-') {
|
|
newFileName += ` - ${quality}`;
|
|
}
|
|
newFileName += ext;
|
|
|
|
expect(newFileName).toBe('Show S01E01.mp4');
|
|
});
|
|
|
|
it('handles show name with tvdbid bracket', () => {
|
|
const showFolderName = 'Test Show [tvdbid-12345]';
|
|
let showName = showFolderName.replace(/\s*\[tvdbid-[^\]]+\]/, '').trim();
|
|
expect(showName).toBe('Test Show');
|
|
});
|
|
|
|
it('handles show name with semicolons', () => {
|
|
const showFolderName = ';;;Test Show';
|
|
let showName = showFolderName.replace(/\s*\[tvdbid-[^\]]+\]/, '').trim();
|
|
showName = showName.replace(/^[;:]+/, '').trim();
|
|
expect(showName).toBe('Test Show');
|
|
});
|
|
|
|
it('extracts season number from Season XX format', () => {
|
|
const seasonFolder = 'Season 05';
|
|
const seasonMatch = seasonFolder.match(/(?:season\s*|s)(\d+)/i);
|
|
expect(parseInt(seasonMatch[1])).toBe(5);
|
|
});
|
|
|
|
it('extracts season number from SXX format', () => {
|
|
const seasonFolder = 'S03';
|
|
const seasonMatch = seasonFolder.match(/(?:season\s*|s)(\d+)/i);
|
|
expect(parseInt(seasonMatch[1])).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('main.js - rename-file handler logic', () => {
|
|
let fs;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
fs = require('fs');
|
|
fs.existsSync = vi.fn(() => true);
|
|
fs.accessSync = vi.fn();
|
|
fs.renameSync = vi.fn();
|
|
});
|
|
|
|
it('returns error when original file does not exist', () => {
|
|
fs.existsSync = vi.fn(() => false);
|
|
const oldPath = '/path/to/file.mp4';
|
|
let result;
|
|
if (!fs.existsSync(oldPath)) {
|
|
result = { success: false, error: 'Original file does not exist' };
|
|
}
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Original file does not exist');
|
|
});
|
|
|
|
it('constructs new path correctly', () => {
|
|
const oldPath = '/path/to/old.mp4';
|
|
const newName = 'new.mp4';
|
|
const newPath = path.join(path.dirname(oldPath), newName);
|
|
expect(newPath).toBe('/path/to/new.mp4');
|
|
});
|
|
});
|
|
|
|
describe('main.js - move-file-to-folder handler logic', () => {
|
|
let fs;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
fs = require('fs');
|
|
fs.existsSync = vi.fn(() => true);
|
|
});
|
|
|
|
it('returns error when file does not exist', () => {
|
|
fs.existsSync = vi.fn(() => false);
|
|
const filePath = '/nonexistent.mp4';
|
|
let result;
|
|
if (!fs.existsSync(filePath)) {
|
|
result = { success: false, error: 'File does not exist' };
|
|
}
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('File does not exist');
|
|
});
|
|
|
|
it('returns error for invalid folder name', () => {
|
|
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
|
|
const folderName = 'invalid-folder';
|
|
let result;
|
|
if (!validFolders.includes(folderName)) {
|
|
result = { success: false, error: 'Invalid folder name' };
|
|
}
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('allows all valid folder names', () => {
|
|
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
|
|
validFolders.forEach((fn) => {
|
|
expect(validFolders.includes(fn)).toBe(true);
|
|
});
|
|
});
|
|
});
|