From 1988212b8a62afa89785ee6c22e2531c27e77352 Mon Sep 17 00:00:00 2001 From: Christian Emmer Date: Tue, 20 Sep 2022 17:55:38 -0700 Subject: [PATCH] Feature: handle ROMs with headers when calculating checksums (#47) --- .editorconfig | 9 ++ .gitattributes | 2 + .gitignore | 3 +- README.md | 16 +- package.json | 1 + scripts/update-readme-help.sh | 2 +- src/console/progressBar.ts | 1 + src/console/singleBarFormatted.ts | 2 +- src/constants.ts | 7 + src/igir.ts | 18 ++- src/modules/argumentsParser.ts | 19 ++- src/modules/candidateGenerator.ts | 32 ++-- src/modules/headerProcessor.ts | 60 ++++++++ src/modules/romScanner.ts | 1 - src/modules/romWriter.ts | 14 +- src/modules/scanner.ts | 2 +- src/polyfill/fsPoly.ts | 2 +- src/types/archives/archive.ts | 21 +++ .../{files => archives}/archiveFactory.ts | 0 src/types/{files => archives}/rar.ts | 13 +- src/types/{files => archives}/sevenZip.ts | 13 +- src/types/{files => archives}/zip.ts | 13 +- src/types/files/archive.ts | 11 -- src/types/files/archiveEntry.ts | 32 +++- src/types/files/file.ts | 90 ++++++++--- src/types/files/fileHeader.ts | 109 +++++++++++++ src/types/logiqx/clrMamePro.ts | 28 +++- src/types/logiqx/dat.ts | 18 +-- src/types/logiqx/header.ts | 84 +++++----- src/types/logiqx/parent.ts | 14 -- src/types/options.ts | 53 +++---- test/console/logger.test.ts | 26 ++-- test/fixtures/roms/7z/fizzbuzz.7z | Bin 143 -> 143 bytes test/fixtures/roms/7z/foobar.7z | Bin 141 -> 141 bytes test/fixtures/roms/fizzbuzz.zip | Bin 183 -> 183 bytes test/fixtures/roms/{foobar.rom => foobar.lnx} | 0 .../fixtures/roms/headered/LCDTestROM.lnx.rar | Bin 0 -> 3562 bytes test/fixtures/roms/headered/allpads.nes | Bin 0 -> 32784 bytes .../color_test.nintendoentertainmentsystem | Bin 0 -> 40976 bytes .../headered/diagnostic_test_cartridge.a78.7z | Bin 0 -> 5414 bytes .../roms/headered/fds_joypad_test.fds.zip | Bin 0 -> 4051 bytes test/fixtures/roms/rar/fizzbuzz.rar | Bin 80 -> 80 bytes test/fixtures/roms/rar/foobar.rar | Bin 76 -> 76 bytes .../roms/raw/{fizzbuzz.rom => fizzbuzz.nes} | 0 .../roms/raw/{foobar.rom => foobar.lnx} | 0 test/fixtures/roms/zip/fizzbuzz.zip | Bin 183 -> 183 bytes test/fixtures/roms/zip/foobar.zip | Bin 207 -> 191 bytes test/igir.test.ts | 18 ++- test/modules/argumentsParser.test.ts | 8 + test/modules/candidateFilter.test.ts | 4 +- test/modules/candidateGenerator.test.ts | 2 +- test/modules/datScanner.test.ts | 2 +- test/modules/headerProcessor.test.ts | 76 ++++++++++ test/modules/outputCleaner.test.ts | 26 ++-- test/modules/reportGenerator.test.ts | 2 + test/modules/romScanner.test.ts | 33 ++-- test/modules/romWriter.test.ts | 56 ++++--- test/modules/statusGenerator.test.ts | 2 + test/types/files/archive.test.ts | 16 +- test/types/files/archiveEntry.test.ts | 143 ++++++++++++------ test/types/files/file.test.ts | 38 ++++- test/types/files/fileHeader.test.ts | 81 ++++++++++ test/types/releaseCandidate.test.ts | 4 +- 63 files changed, 886 insertions(+), 341 deletions(-) create mode 100644 .editorconfig create mode 100644 src/modules/headerProcessor.ts create mode 100644 src/types/archives/archive.ts rename src/types/{files => archives}/archiveFactory.ts (100%) rename src/types/{files => archives}/rar.ts (76%) rename src/types/{files => archives}/sevenZip.ts (84%) rename src/types/{files => archives}/zip.ts (74%) delete mode 100644 src/types/files/archive.ts create mode 100644 src/types/files/fileHeader.ts rename test/fixtures/roms/{foobar.rom => foobar.lnx} (100%) create mode 100644 test/fixtures/roms/headered/LCDTestROM.lnx.rar create mode 100644 test/fixtures/roms/headered/allpads.nes create mode 100644 test/fixtures/roms/headered/color_test.nintendoentertainmentsystem create mode 100644 test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z create mode 100644 test/fixtures/roms/headered/fds_joypad_test.fds.zip rename test/fixtures/roms/raw/{fizzbuzz.rom => fizzbuzz.nes} (100%) rename test/fixtures/roms/raw/{foobar.rom => foobar.lnx} (100%) create mode 100644 test/modules/headerProcessor.test.ts create mode 100644 test/modules/reportGenerator.test.ts create mode 100644 test/modules/statusGenerator.test.ts create mode 100644 test/types/files/fileHeader.test.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..74fb9e0be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf diff --git a/.gitattributes b/.gitattributes index 6727e0cf4..09c000ee3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ # Stop `core.autocrlf true` +*.lnx binary +*.nes binary *.rom binary diff --git a/.gitignore b/.gitignore index 2882c5760..2173f090e 100644 --- a/.gitignore +++ b/.gitignore @@ -106,7 +106,8 @@ dist # Custom build/ -demo-magic.sh +*.bat +*.sh # DATs *.dat diff --git a/README.md b/README.md index d0073eddb..18ea399a0 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ With a large ROM collection it can be difficult to: `igir` needs two sets of files: -1. ROMs, of course! +1. ROMs, including ones with [headers](https://no-intro.org/faq.htm) 2. One or more DATs ([see below](#what-are-dats) for where to download) -Many different input archive types are supported: .001, .7z, .bz2, .gz, .rar, .tar, .xz, .z, .z01, .zip, .zipx, and more! +Many different input archive types are supported for both ROMs and DATs: .001, .7z, .bz2, .gz, .rar, .tar, .tgz, .xz, .z, .z01, .zip, .zipx, and more! `igir` then needs one or more commands: @@ -57,8 +57,8 @@ npx igir@latest [commands..] [options] Here is the full `igir --help` message which shows all available options and a number of common use case examples: ```help - ______ ______ ______ _______ -| \ / \ | \| \ + ______ ______ ______ _______ +| \ / \ | \| \ \$$$$$$| $$$$$$\ \$$$$$$| $$$$$$$\ | $$ | $$ __\$$ | $$ | $$__| $$ | $$ | $$| \ | $$ | $$ $$ @@ -85,6 +85,9 @@ Path options (inputs support globbing): -I, --input-exclude Path(s) to ROM files to exclude [array] -o, --output Path to the ROM output directory [string] +Input options: + -H, --header Glob pattern of files to force header processing for [string] + Output options: --dir-mirror Use the input subdirectory structure for output subdirectories [boolean] -D, --dir-dat-name Use the DAT name as the output subdirectory [boolean] @@ -95,7 +98,7 @@ Output options: -Z, --zip-exclude Glob pattern of files to exclude from zipping [string] -O, --overwrite Overwrite any ROMs in the output directory [boolean] -Priority options: +Priority options (requires --single): --prefer-good Prefer good ROM dumps over bad [boolean] -l, --prefer-language List of comma-separated languages in priority order (supported: DA, DE, EL, EN, ES, FI, FR, IT, JA, KO, NL, NO, PT, RU, SV, ZH) @@ -197,11 +200,12 @@ There a few different popular ROM managers that have similar features: Each manager has its own pros, but most share the same cons: -- Windows-only (sometimes with Wine support), making management on macOS and Linux difficult +- Windows-only (sometimes with Wine support), making management on macOS and Linux difficult - Limited CLI support, making batching and repeatable actions difficult - UIs that don't clearly state what actions can, will, or are being performed - Required proprietary database setup step - Limited or nonexistent archive extraction support +- Limited or nonexistent ROM header support - Limited or nonexistent parent/clone, region, language, version, and ROM type filtering - Limited or nonexistent priorities when creating a 1G1R set - Limited or nonexistent folder management options diff --git a/package.json b/package.json index 3bcd0f1b0..04e587688 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "keywords": [ "1g1r", "emulation", + "logiqx", "no-intro", "roms" ], diff --git a/scripts/update-readme-help.sh b/scripts/update-readme-help.sh index 1f7393ce9..cd298c572 100755 --- a/scripts/update-readme-help.sh +++ b/scripts/update-readme-help.sh @@ -9,7 +9,7 @@ cd "$(dirname "$0")/.." README="README.md" -HELP="$(./node_modules/.bin/ts-node ./index.ts --help "${1:-80}")" +HELP="$(./node_modules/.bin/ts-node ./index.ts --help "${1:-80}" | sed 's/ *$//g')" (awk 'BEGIN {msg=ARGV[1]; delete ARGV[1]; p=1} /^```help/ {print; print msg; p=0} /^```$/ {p=1} p' \ "${HELP}" \ "${README}" > "${README}.temp" || exit 1) && mv -f "${README}.temp" "${README}" diff --git a/src/console/progressBar.ts b/src/console/progressBar.ts index e76c4323f..c5f4cdf52 100644 --- a/src/console/progressBar.ts +++ b/src/console/progressBar.ts @@ -5,6 +5,7 @@ import LogLevel from './logLevel.js'; export const Symbols: { [key: string]: string } = { WAITING: chalk.grey('⋯'), SEARCHING: chalk.magenta('↻'), + HASHING: chalk.magenta('#'), GENERATING: chalk.cyan('Σ'), PROCESSING: chalk.cyan('⚙'), FILTERING: chalk.cyan('∆'), diff --git a/src/console/singleBarFormatted.ts b/src/console/singleBarFormatted.ts index a61ec7e69..3d921e60d 100644 --- a/src/console/singleBarFormatted.ts +++ b/src/console/singleBarFormatted.ts @@ -123,7 +123,7 @@ export default class SingleBarFormatted { const seconds = this.eta as number; const secondsRounded = 5 * Math.round(seconds / 5); if (secondsRounded >= 3600) { - return `${Math.floor(secondsRounded / 3600)}h${(secondsRounded % 3600) / 60}m`; + return `${Math.floor(secondsRounded / 3600)}h${Math.floor((secondsRounded % 3600) / 60)}m`; } if (secondsRounded >= 60) { return `${Math.floor(secondsRounded / 60)}m${(secondsRounded % 60)}s`; } if (seconds >= 10) { diff --git a/src/constants.ts b/src/constants.ts index fcd5c79ea..f8f91c77a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,7 +17,14 @@ export default class Constants { static readonly ROM_SCANNER_THREADS = 25; + // TODO(cemmer): is there a way to set a global limit with only one DAT? semaphores? + static readonly ROM_HEADER_HASHER_THREADS = Math.ceil( + Constants.ROM_SCANNER_THREADS / Constants.DAT_THREADS, + ); + static readonly ROM_WRITER_THREADS = Math.ceil( Constants.ROM_SCANNER_THREADS / Constants.DAT_THREADS, ); + + static readonly FILE_READING_CHUNK_SIZE = 1024 * 1024; // 1MiB } diff --git a/src/igir.ts b/src/igir.ts index 4e19d3216..c6daa42d4 100644 --- a/src/igir.ts +++ b/src/igir.ts @@ -7,6 +7,7 @@ import Constants from './constants.js'; import CandidateFilter from './modules/candidateFilter.js'; import CandidateGenerator from './modules/candidateGenerator.js'; import DATScanner from './modules/datScanner.js'; +import HeaderProcessor from './modules/headerProcessor.js'; import OutputCleaner from './modules/outputCleaner.js'; import ReportGenerator from './modules/reportGenerator.js'; import ROMScanner from './modules/romScanner.js'; @@ -29,13 +30,17 @@ export default class Igir { } async main(): Promise { + // Scan and process input files const dats = await this.processDATScanner(); - const romFiles = await this.processROMScanner(); + const rawRomFiles = await this.processROMScanner(); + const processedRomFiles = await this.processHeaderProcessor(rawRomFiles); + // Set up progress bar and input for DAT processing const datProcessProgressBar = this.logger.addProgressBar('Processing DATs', Symbols.PROCESSING, dats.length); const datsToWrittenRoms = new Map>(); const datsStatuses: DATStatus[] = []; + // Process every DAT await async.eachLimit(dats, Constants.DAT_THREADS, async (dat, callback) => { const progressBar = this.logger.addProgressBar( dat.getNameShort(), @@ -45,7 +50,8 @@ export default class Igir { await datProcessProgressBar.increment(); // Generate and filter ROM candidates - const romCandidates = await new CandidateGenerator(progressBar).generate(dat, romFiles); + const romCandidates = await new CandidateGenerator(progressBar) + .generate(dat, processedRomFiles); const romOutputs = await new CandidateFilter(this.options, progressBar) .filter(dat, romCandidates); @@ -98,6 +104,14 @@ export default class Igir { return romInputs; } + private async processHeaderProcessor(romFiles: File[]): Promise { + const headerProcessorProgressBar = this.logger.addProgressBar('Reading ROM headers', Symbols.WAITING); + const processedRomFiles = await new HeaderProcessor(this.options, headerProcessorProgressBar) + .process(romFiles); + await headerProcessorProgressBar.doneItems(processedRomFiles.length, 'file', 'read'); + return processedRomFiles; + } + private async processOutputCleaner( datsToWrittenRoms: Map>, ): Promise { diff --git a/src/modules/argumentsParser.ts b/src/modules/argumentsParser.ts index 23d1e62ed..0c7e66586 100644 --- a/src/modules/argumentsParser.ts +++ b/src/modules/argumentsParser.ts @@ -43,10 +43,11 @@ export default class ArgumentsParser { this.logger.info(`Parsing CLI arguments: ${argv}`); const groupInputOutputPaths = 'Path options (inputs support globbing):'; + const groupInput = 'Input options:'; const groupOutput = 'Output options:'; - const groupPriority = 'Priority options:'; + const groupPriority = 'Priority options (requires --single):'; const groupFiltering = 'Filtering options:'; - const groupDebug = 'Debug options:'; + const groupHelp = 'Help options:'; // Add every command to a yargs object, recursively, resulting in the ability to specify // multiple commands @@ -128,6 +129,15 @@ export default class ArgumentsParser { return true; }) + .option('header', { + group: groupInput, + alias: 'H', + description: 'Glob pattern of files to force header processing for', + type: 'string', + coerce: ArgumentsParser.getLastValue, // don't allow string[] values + requiresArg: true, + }) + .option('dir-mirror', { group: groupOutput, description: 'Use the input subdirectory structure for output subdirectories', @@ -297,7 +307,7 @@ export default class ArgumentsParser { }) .option('verbose', { - group: groupDebug, + group: groupHelp, alias: 'v', description: 'Enable verbose logging, can specify twice (-vv)', type: 'count', @@ -317,8 +327,9 @@ export default class ArgumentsParser { ['$0 copy -i ROMs/ -o /media/SDCard/ROMs/ -D --dir-letter -t', 'Copy ROMs to a flash cart and test them'], ]) - // Colorize help output + // Colorize help output .option('help', { + group: groupHelp, alias: 'h', description: 'Show help', type: 'boolean', diff --git a/src/modules/candidateGenerator.ts b/src/modules/candidateGenerator.ts index b8f971216..e64b6971e 100644 --- a/src/modules/candidateGenerator.ts +++ b/src/modules/candidateGenerator.ts @@ -31,13 +31,14 @@ export default class CandidateGenerator { return output; } + await this.progressBar.setSymbol(Symbols.GENERATING); + await this.progressBar.reset(dat.getParents().length); + + // TODO(cemmer): use filesize combined with CRC for indexing // TODO(cemmer): ability to index files by some other property such as name const crc32ToInputFiles = await CandidateGenerator.indexFilesByCrc(inputRomFiles); await this.progressBar.logInfo(`${dat.getName()}: ${crc32ToInputFiles.size} unique ROM CRC32s found`); - await this.progressBar.setSymbol(Symbols.GENERATING); - await this.progressBar.reset(dat.getParents().length); - // TODO(cemmer): ability to work without DATs, generating a parent/game/release per file // For each parent, try to generate a parent candidate /* eslint-disable no-await-in-loop */ @@ -79,20 +80,25 @@ export default class CandidateGenerator { private static async indexFilesByCrc(files: File[]): Promise> { return files.reduce(async (accPromise, file) => { const acc = await accPromise; - if (acc.has(await file.getCrc32())) { - // Have already seen file, prefer non-archived files - const existing = acc.get(await file.getCrc32()) as File; - if (!(file instanceof ArchiveEntry) && existing instanceof ArchiveEntry) { - acc.set(await file.getCrc32(), file); - } - } else { - // Haven't seen file yet, store it - acc.set(await file.getCrc32(), file); - } + this.addToIndex(acc, await file.getCrc32(), file); + this.addToIndex(acc, await file.getCrc32WithoutHeader(), file); return acc; }, Promise.resolve(new Map())); } + private static addToIndex(map: Map, hash: string, file: File): void { + if (map.has(hash)) { + // Have already seen file, prefer non-archived files + const existing = map.get(hash) as File; + if (!(file instanceof ArchiveEntry) && existing instanceof ArchiveEntry) { + map.set(hash, file); + } + } else { + // Haven't seen file yet, store it + map.set(hash, file); + } + } + private async buildReleaseCandidateForRelease( game: Game, release: Release | undefined, diff --git a/src/modules/headerProcessor.ts b/src/modules/headerProcessor.ts new file mode 100644 index 000000000..127f3c994 --- /dev/null +++ b/src/modules/headerProcessor.ts @@ -0,0 +1,60 @@ +import async, { AsyncResultCallback } from 'async'; + +import ProgressBar, { Symbols } from '../console/progressBar.js'; +import Constants from '../constants.js'; +import File from '../types/files/file.js'; +import FileHeader from '../types/files/fileHeader.js'; +import Options from '../types/options.js'; + +// TODO(cemmer): put debug statements in here +export default class HeaderProcessor { + private readonly options: Options; + + private readonly progressBar: ProgressBar; + + constructor(options: Options, progressBar: ProgressBar) { + this.options = options; + this.progressBar = progressBar; + } + + async process(inputRomFiles: File[]): Promise { + await this.progressBar.logInfo('Processing file headers'); + + await this.progressBar.setSymbol(Symbols.HASHING); + await this.progressBar.reset(inputRomFiles.length); + + return async.mapLimit( + inputRomFiles, + Constants.ROM_HEADER_HASHER_THREADS, + async (inputFile, callback: AsyncResultCallback) => { + await this.progressBar.increment(); + + // Don't do anything if the file already has a header + if (inputFile.getFileHeader()) { + return callback(null, inputFile); + } + + // Can get FileHeader from extension, use that + const headerForExtension = FileHeader.getForFilename(inputFile.getExtractedFilePath()); + if (headerForExtension) { + const fileWithHeader = await inputFile.withFileHeader(headerForExtension).resolve(); + return callback(null, fileWithHeader); + } + + // Should get FileHeader from File, try to + if (this.options.shouldReadFileForHeader(inputFile.getExtractedFilePath())) { + const headerForFile = await inputFile + .extract(async (localFile) => FileHeader.getForFileContents(localFile)); + if (headerForFile) { + const fileWithHeader = await inputFile.withFileHeader(headerForFile).resolve(); + return callback(null, fileWithHeader); + } + await this.progressBar.logWarn(`Couldn't detect header for ${inputFile.toString()}`); + } + + // Should not get FileHeader + return callback(null, inputFile); + }, + ); + } +} diff --git a/src/modules/romScanner.ts b/src/modules/romScanner.ts index 51fd17fad..b5c21a538 100644 --- a/src/modules/romScanner.ts +++ b/src/modules/romScanner.ts @@ -12,7 +12,6 @@ import Scanner from './scanner.js'; * This class will not be run concurrently with any other class. */ export default class ROMScanner extends Scanner { - // TODO(cemmer): support for headered ROM files (e.g. NES) async scan(): Promise { await this.progressBar.setSymbol(Symbols.SEARCHING); await this.progressBar.reset(0); diff --git a/src/modules/romWriter.ts b/src/modules/romWriter.ts index d21ac1ee8..50ca9f263 100644 --- a/src/modules/romWriter.ts +++ b/src/modules/romWriter.ts @@ -6,9 +6,9 @@ import path from 'path'; import ProgressBar, { Symbols } from '../console/progressBar.js'; import Constants from '../constants.js'; import fsPoly from '../polyfill/fsPoly.js'; +import Zip from '../types/archives/zip.js'; import ArchiveEntry from '../types/files/archiveEntry.js'; import File from '../types/files/file.js'; -import Zip from '../types/files/zip.js'; import DAT from '../types/logiqx/dat.js'; import Parent from '../types/logiqx/parent.js'; import ROM from '../types/logiqx/rom.js'; @@ -107,8 +107,10 @@ export default class ROMWriter { const crcToRoms = releaseCandidate.getRomsByCrc32(); return releaseCandidate.getFiles().reduce(async (accPromise, inputFile) => { + // TODO(cemmer): use filesize combined with CRC for indexing const acc = await accPromise; - const rom = crcToRoms.get(await inputFile.getCrc32()) as ROM; + const rom = crcToRoms.get(await inputFile.getCrc32()) + || crcToRoms.get(await inputFile.getCrc32WithoutHeader()) as ROM; let outputFile: File; if (this.options.shouldZip(rom.getName())) { @@ -221,9 +223,11 @@ export default class ROMWriter { // If the file in the output zip already exists and has the same CRC then do nothing const existingOutputEntry = outputZip.getEntry(outputRomFile.getEntryPath() as string); - if (existingOutputEntry?.header.crc === parseInt(await outputRomFile.getCrc32(), 16)) { - await this.progressBar.logDebug(`${outputZipPath}: ${outputRomFile.getEntryPath()} already exists`); - return false; + if (existingOutputEntry) { + if (existingOutputEntry.header.crc === parseInt(await outputRomFile.getCrc32(), 16)) { + await this.progressBar.logDebug(`${outputZipPath}: ${outputRomFile.getEntryPath()} already exists`); + return false; + } } // Write the entry diff --git a/src/modules/scanner.ts b/src/modules/scanner.ts index eb0cdeb78..388ada329 100644 --- a/src/modules/scanner.ts +++ b/src/modules/scanner.ts @@ -1,5 +1,5 @@ import ProgressBar from '../console/progressBar.js'; -import ArchiveFactory from '../types/files/archiveFactory.js'; +import ArchiveFactory from '../types/archives/archiveFactory.js'; import File from '../types/files/file.js'; import Options from '../types/options.js'; diff --git a/src/polyfill/fsPoly.ts b/src/polyfill/fsPoly.ts index 23dafd7ca..c228df562 100644 --- a/src/polyfill/fsPoly.ts +++ b/src/polyfill/fsPoly.ts @@ -77,7 +77,7 @@ export default class FsPoly { fs.rmdirSync(pathLike, options); } else { // Added in: v14.14.0 - fs.rmSync(pathLike, { recursive: true, force: true }); + fs.rmSync(pathLike, { ...options, force: true }); } } else { // Added in: v0.1.21 diff --git a/src/types/archives/archive.ts b/src/types/archives/archive.ts new file mode 100644 index 000000000..1a0a98430 --- /dev/null +++ b/src/types/archives/archive.ts @@ -0,0 +1,21 @@ +import ArchiveEntry from '../files/archiveEntry.js'; + +export default abstract class Archive { + private readonly filePath: string; + + constructor(filePath: string) { + this.filePath = filePath; + } + + getFilePath(): string { + return this.filePath; + } + + abstract getArchiveEntries(): Promise; + + abstract extractEntry( + archiveEntry: ArchiveEntry, + tempDir: string, + callback: (localFile: string) => (T | Promise), + ): Promise; +} diff --git a/src/types/files/archiveFactory.ts b/src/types/archives/archiveFactory.ts similarity index 100% rename from src/types/files/archiveFactory.ts rename to src/types/archives/archiveFactory.ts diff --git a/src/types/files/rar.ts b/src/types/archives/rar.ts similarity index 76% rename from src/types/files/rar.ts rename to src/types/archives/rar.ts index fbdd94c17..9aee1d453 100644 --- a/src/types/files/rar.ts +++ b/src/types/archives/rar.ts @@ -1,11 +1,8 @@ -import { promises as fsPromises } from 'fs'; import unrar from 'node-unrar-js'; import path from 'path'; -import Constants from '../../constants.js'; -import fsPoly from '../../polyfill/fsPoly.js'; +import ArchiveEntry from '../files/archiveEntry.js'; import Archive from './archive.js'; -import ArchiveEntry from './archiveEntry.js'; export default class Rar extends Archive { static readonly SUPPORTED_EXTENSIONS = ['.rar']; @@ -24,9 +21,9 @@ export default class Rar extends Archive { async extractEntry( archiveEntry: ArchiveEntry, + tempDir: string, callback: (localFile: string) => (T | Promise), ): Promise { - const tempDir = await fsPromises.mkdtemp(Constants.GLOBAL_TEMP_DIR); const localFile = path.join(tempDir, archiveEntry.getEntryPath() as string); const rar = await unrar.createExtractorFromFile({ @@ -40,10 +37,6 @@ export default class Rar extends Archive { files: [archiveEntry.getEntryPath()], }).files]; - try { - return await callback(localFile); - } finally { - fsPoly.rmSync(tempDir, { recursive: true }); - } + return callback(localFile); } } diff --git a/src/types/files/sevenZip.ts b/src/types/archives/sevenZip.ts similarity index 84% rename from src/types/files/sevenZip.ts rename to src/types/archives/sevenZip.ts index e4b660fa2..0b80d5c0a 100644 --- a/src/types/files/sevenZip.ts +++ b/src/types/archives/sevenZip.ts @@ -1,12 +1,9 @@ import _7z, { Result } from '7zip-min'; import { Mutex } from 'async-mutex'; -import { promises as fsPromises } from 'fs'; import path from 'path'; -import Constants from '../../constants.js'; -import fsPoly from '../../polyfill/fsPoly.js'; +import ArchiveEntry from '../files/archiveEntry.js'; import Archive from './archive.js'; -import ArchiveEntry from './archiveEntry.js'; export default class SevenZip extends Archive { // p7zip `7za i` @@ -56,9 +53,9 @@ export default class SevenZip extends Archive { async extractEntry( archiveEntry: ArchiveEntry, + tempDir: string, callback: (localFile: string) => (T | Promise), ): Promise { - const tempDir = await fsPromises.mkdtemp(Constants.GLOBAL_TEMP_DIR); const localFile = path.join(tempDir, archiveEntry.getEntryPath()); await new Promise((resolve, reject) => { @@ -71,10 +68,6 @@ export default class SevenZip extends Archive { }); }); - try { - return await callback(localFile); - } finally { - fsPoly.rmSync(tempDir, { recursive: true }); - } + return callback(localFile); } } diff --git a/src/types/files/zip.ts b/src/types/archives/zip.ts similarity index 74% rename from src/types/files/zip.ts rename to src/types/archives/zip.ts index 338c984df..ff9cf42a1 100644 --- a/src/types/files/zip.ts +++ b/src/types/archives/zip.ts @@ -1,11 +1,8 @@ import AdmZip, { IZipEntry } from 'adm-zip'; -import { promises as fsPromises } from 'fs'; import path from 'path'; -import Constants from '../../constants.js'; -import fsPoly from '../../polyfill/fsPoly.js'; +import ArchiveEntry from '../files/archiveEntry.js'; import Archive from './archive.js'; -import ArchiveEntry from './archiveEntry.js'; export default class Zip extends Archive { static readonly SUPPORTED_EXTENSIONS = ['.zip']; @@ -23,9 +20,9 @@ export default class Zip extends Archive { async extractEntry( archiveEntry: ArchiveEntry, + tempDir: string, callback: (localFile: string) => (T | Promise), ): Promise { - const tempDir = await fsPromises.mkdtemp(Constants.GLOBAL_TEMP_DIR); const localFile = path.join(tempDir, archiveEntry.getEntryPath()); const zip = new AdmZip(this.getFilePath()); @@ -42,10 +39,6 @@ export default class Zip extends Archive { archiveEntry.getEntryPath(), ); - try { - return await callback(localFile); - } finally { - fsPoly.rmSync(tempDir, { recursive: true }); - } + return callback(localFile); } } diff --git a/src/types/files/archive.ts b/src/types/files/archive.ts deleted file mode 100644 index b450c29de..000000000 --- a/src/types/files/archive.ts +++ /dev/null @@ -1,11 +0,0 @@ -import ArchiveEntry from './archiveEntry.js'; -import File from './file.js'; - -export default abstract class Archive extends File { - abstract getArchiveEntries(): Promise; - - abstract extractEntry( - archiveEntry: ArchiveEntry, - callback: (localFile: string) => (T | Promise), - ): Promise; -} diff --git a/src/types/files/archiveEntry.ts b/src/types/files/archiveEntry.ts index 53fff30dd..c7cefcf4e 100644 --- a/src/types/files/archiveEntry.ts +++ b/src/types/files/archiveEntry.ts @@ -1,23 +1,47 @@ -import Archive from './archive.js'; +import { promises as fsPromises } from 'fs'; + +import Constants from '../../constants.js'; +import fsPoly from '../../polyfill/fsPoly.js'; +import Archive from '../archives/archive.js'; import File from './file.js'; +import FileHeader from './fileHeader.js'; export default class ArchiveEntry extends File { private readonly archive: Archive; private readonly entryPath: string; - constructor(archive: Archive, entryPath: string, crc?: string) { - super(archive.getFilePath(), crc); + constructor(archive: Archive, entryPath: string, crc?: string, fileHeader?: FileHeader) { + super(archive.getFilePath(), crc, fileHeader); this.archive = archive; this.entryPath = entryPath; } + getExtractedFilePath(): string { + return this.entryPath; + } + getEntryPath(): string { return this.entryPath; } async extract(callback: (localFile: string) => (T | Promise)): Promise { - return this.archive.extractEntry(this, callback); + const tempDir = await fsPromises.mkdtemp(Constants.GLOBAL_TEMP_DIR); + + try { + return await this.archive.extractEntry(this, tempDir, callback); + } finally { + fsPoly.rmSync(tempDir, { recursive: true }); + } + } + + withFileHeader(fileHeader: FileHeader): File { + return new ArchiveEntry( + this.archive, + this.entryPath, + undefined, // the old CRC can't be used, a header will change it + fileHeader, + ); } toString(): string { diff --git a/src/types/files/file.ts b/src/types/files/file.ts index 73b69626d..83707a540 100644 --- a/src/types/files/file.ts +++ b/src/types/files/file.ts @@ -1,58 +1,97 @@ import crc32 from 'crc/crc32'; -import fs, { PathLike } from 'fs'; +import fs from 'fs'; import path from 'path'; +import Constants from '../../constants.js'; +import FileHeader from './fileHeader.js'; + export default class File { private readonly filePath: string; private crc32?: Promise; - constructor(filePath: string, crc?: string) { + private crc32WithoutHeader?: Promise; + + private readonly fileHeader?: FileHeader; + + constructor(filePath: string, crc?: string, fileHeader?: FileHeader) { this.filePath = filePath; if (crc) { this.crc32 = Promise.resolve(crc); } + this.fileHeader = fileHeader; } getFilePath(): string { return this.filePath; } + getExtractedFilePath(): string { + return this.filePath; + } + async getCrc32(): Promise { if (!this.crc32) { - this.crc32 = File.calculateCrc32(this.filePath); + this.crc32 = this.calculateCrc32(false); } return (await this.crc32).toLowerCase().padStart(8, '0'); } + async getCrc32WithoutHeader(): Promise { + if (!this.fileHeader) { + return this.getCrc32(); + } + + if (!this.crc32WithoutHeader) { + this.crc32WithoutHeader = this.calculateCrc32(true); + } + return (await this.crc32WithoutHeader).toLowerCase().padStart(8, '0'); + } + + getFileHeader(): FileHeader | undefined { + return this.fileHeader; + } + + // TODO(cemmer): figure out how to eliminate this isZip(): boolean { return path.extname(this.getFilePath()).toLowerCase() === '.zip'; } - private static async calculateCrc32(pathLike: PathLike): Promise { - return new Promise((resolve, reject) => { - const stream = fs.createReadStream(pathLike, { - highWaterMark: 1024 * 1024, // 1MB - }); + private async calculateCrc32(processHeader: boolean): Promise { + return this.extract(async (localFile) => { + // If we're hashing a file with a header, make sure the file actually has the header magic + // string before excluding it + let start = 0; + if (processHeader && this.fileHeader && await this.fileHeader.fileHasHeader(localFile)) { + start = this.fileHeader.dataOffsetBytes; + } - let crc: number; - stream.on('data', (chunk) => { - if (!crc) { - crc = crc32(chunk); - } else { - crc = crc32(chunk, crc); - } - }); - stream.on('end', () => { - resolve((crc || 0).toString(16)); - }); + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(localFile, { + start, + highWaterMark: Constants.FILE_READING_CHUNK_SIZE, + }); + + let crc: number; + stream.on('data', (chunk) => { + if (!crc) { + crc = crc32(chunk); + } else { + crc = crc32(chunk, crc); + } + }); + stream.on('end', () => { + resolve((crc || 0).toString(16)); + }); - stream.on('error', (err) => reject(err)); + stream.on('error', (err) => reject(err)); + }); }); } async resolve(): Promise { await this.getCrc32(); + await this.getCrc32WithoutHeader(); return this; } @@ -60,6 +99,14 @@ export default class File { return callback(this.filePath); } + withFileHeader(fileHeader: FileHeader): File { + return new File( + this.filePath, + undefined, // the old CRC can't be used, a header will change it + fileHeader, + ); + } + /** ************************* * * * Pseudo Built-Ins * @@ -75,6 +122,7 @@ export default class File { return true; } return this.getFilePath() === other.getFilePath() - && await this.getCrc32() === await other.getCrc32(); + && await this.getCrc32() === await other.getCrc32() + && await this.getCrc32WithoutHeader() === await other.getCrc32WithoutHeader(); } } diff --git a/src/types/files/fileHeader.ts b/src/types/files/fileHeader.ts new file mode 100644 index 000000000..e57ca1b86 --- /dev/null +++ b/src/types/files/fileHeader.ts @@ -0,0 +1,109 @@ +import fs from 'fs'; +import path from 'path'; + +import Constants from '../../constants.js'; + +export default class FileHeader { + private static readonly HEADERS: { [key: string]:FileHeader } = { + // http://7800.8bitdev.org/index.php/A78_Header_Specification + 'No-Intro_A7800.xml': new FileHeader(1, '415441524937383030', 128, '.a78'), + + // https://atarigamer.com/lynx/lnxhdrgen + 'No-Intro_LNX.xml': new FileHeader(0, '4C594E58', 64, '.lnx'), + + // https://www.nesdev.org/wiki/INES + 'No-Intro_NES.xml': new FileHeader(0, '4E4553', 16, '.nes'), + + // https://www.nesdev.org/wiki/FDS_file_format + 'No-Intro_FDS.xml': new FileHeader(0, '464453', 16, '.fds'), + }; + + private static readonly MAX_HEADER_LENGTH = Object.values(FileHeader.HEADERS) + .reduce((max, fileHeader) => Math.max( + max, + fileHeader.headerOffsetBytes + fileHeader.headerValue.length / 2, + ), 0); + + readonly headerOffsetBytes: number; + + readonly headerValue: string; + + readonly dataOffsetBytes: number; + + readonly fileExtension: string; + + private constructor( + headerOffsetBytes: number, + headerValue: string, + dataOffset: number, + fileExtension: string, + ) { + this.headerOffsetBytes = headerOffsetBytes; + this.headerValue = headerValue; + this.dataOffsetBytes = dataOffset; + this.fileExtension = fileExtension; + } + + static getForName(headerName: string): FileHeader | undefined { + return this.HEADERS[headerName]; + } + + static getForFilename(filePath: string): FileHeader | undefined { + const headers = Object.values(this.HEADERS); + for (let i = 0; i < headers.length; i += 1) { + const header = headers[i]; + if (header.fileExtension.toLowerCase() === path.extname(filePath).toLowerCase()) { + return header; + } + } + return undefined; + } + + private static async readHeader(filePath: string, start: number, end: number): Promise { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath, { + start, + end, + highWaterMark: Constants.FILE_READING_CHUNK_SIZE, + }); + + const chunks: Buffer[] = []; + stream.on('data', (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + stream.on('end', () => { + const header = Buffer.concat(chunks).toString('hex'); + resolve(header.toUpperCase()); + }); + + stream.on('error', (err) => reject(err)); + }); + } + + static async getForFileContents(filePath: string): Promise { + const fileHeader = await FileHeader.readHeader(filePath, 0, this.MAX_HEADER_LENGTH); + + const headers = Object.values(this.HEADERS); + for (let i = 0; i < headers.length; i += 1) { + const header = headers[i]; + const headerValue = fileHeader.slice( + header.headerOffsetBytes * 2, + header.headerOffsetBytes * 2 + header.headerValue.length, + ); + if (headerValue === header.headerValue) { + return header; + } + } + + return undefined; + } + + async fileHasHeader(filePath: string): Promise { + const header = await FileHeader.readHeader( + filePath, + this.headerOffsetBytes, + this.headerOffsetBytes + this.headerValue.length / 2 - 1, + ); + return header.toUpperCase() === this.headerValue.toUpperCase(); + } +} diff --git a/src/types/logiqx/clrMamePro.ts b/src/types/logiqx/clrMamePro.ts index 81e61e663..dc13a8f56 100644 --- a/src/types/logiqx/clrMamePro.ts +++ b/src/types/logiqx/clrMamePro.ts @@ -2,26 +2,44 @@ import 'reflect-metadata'; import { Expose } from 'class-transformer'; +interface ClrMameProOptions { + readonly header?: string; + readonly forceMerging?: 'none' | 'split' | 'full'; + readonly forceNoDump?: 'obsolete' | 'required' | 'ignore'; + readonly forcePacking?: 'zip' | 'unzip'; +} + /** * "CMPro data files use a 'clrmamepro' element to specify details such as the * emulator name, description, category and the data file version." * * @see http://www.logiqx.com/DatFAQs/CMPro.php */ -export default class ClrMamePro { +export default class ClrMamePro implements ClrMameProOptions { + /** + * No-Intro DATs use this to indicate what file header has been added before the raw ROM data. + * {@link FileHeader.HEADERS} + */ @Expose({ name: 'header' }) - private readonly header?: string; + readonly header: string; /** * "To force CMPro to use a particular merging format (none/split/full). Only * do this if the emulator doesn't allow all three of the modes!" */ @Expose({ name: 'forcemerging' }) - private readonly forceMerging: 'none' | 'split' | 'full' = 'split'; + readonly forceMerging: 'none' | 'split' | 'full'; @Expose({ name: 'forcenodump' }) - private readonly forceNoDump: 'obsolete' | 'required' | 'ignore' = 'obsolete'; + readonly forceNoDump: 'obsolete' | 'required' | 'ignore'; @Expose({ name: 'forcepacking' }) - private readonly forcePacking: 'zip' | 'unzip' = 'zip'; + readonly forcePacking: 'zip' | 'unzip'; + + constructor(options?: ClrMameProOptions) { + this.header = options?.header || ''; + this.forceMerging = options?.forceMerging || 'split'; + this.forceNoDump = options?.forceNoDump || 'obsolete'; + this.forcePacking = options?.forcePacking || 'zip'; + } } diff --git a/src/types/logiqx/dat.ts b/src/types/logiqx/dat.ts index 7353677a3..873c119cf 100644 --- a/src/types/logiqx/dat.ts +++ b/src/types/logiqx/dat.ts @@ -59,10 +59,6 @@ export default class DAT { return this.header; } - getName(): string { - return this.getHeader().getName(); - } - getGames(): Game[] { if (this.game instanceof Array) { return this.game; @@ -78,29 +74,29 @@ export default class DAT { // Computed getters + getName(): string { + return this.getHeader().getName(); + } + getNameShort(): string { return this.getName() - // Prefixes + // Prefixes .replace('Non-Redump', '') .replace('Source Code', '') .replace('Unofficial', '') - // Suffixes + // Suffixes .replace('Datfile', '') - .replace('(BigEndian)', '') .replace('(CDN)', '') - .replace('(Decrypted)', '') .replace('(Deprecated)', '') .replace('(Digital)', '') .replace('(Download Play)', '') - .replace('(Headered)', '') .replace('(Misc)', '') - // .replace('(Multiboot)', '') .replace(/\(Parent-Clone\)/g, '') .replace('(PSN)', '') .replace('(Split DLC)', '') .replace('(WAD)', '') .replace('(WIP)', '') - // Cleanup + // Cleanup .replace(/^[ -]+/, '') .replace(/[ -]+$/, '') .replace(/ +/g, ' ') diff --git a/src/types/logiqx/header.ts b/src/types/logiqx/header.ts index ab7c04b81..50f60ce57 100644 --- a/src/types/logiqx/header.ts +++ b/src/types/logiqx/header.ts @@ -6,41 +6,41 @@ import ClrMamePro from './clrMamePro.js'; import RomCenter from './romCenter.js'; interface HeaderOptions { - name?: string; - description?: string; - category?: string; - version?: string; - date?: string; - author?: string; - email?: string; - homepage?: string; - url?: string; - comment?: string; - clrMamePro?: ClrMamePro; - romCenter?: RomCenter; + readonly name?: string; + readonly description?: string; + readonly category?: string; + readonly version?: string; + readonly date?: string; + readonly author?: string; + readonly email?: string; + readonly homepage?: string; + readonly url?: string; + readonly comment?: string; + readonly clrMamePro?: ClrMamePro; + readonly romCenter?: RomCenter; } -export default class Header { +export default class Header implements HeaderOptions { /** * "Name of the emulator without a version number. This field is used by the * update feature of the CMPro profiler." */ @Expose({ name: 'name' }) - private readonly name!: string; + readonly name: string; /** * "Name of the emulator with a version number. This is the name displayed by * CMPro." */ @Expose({ name: 'description' }) - private readonly description!: string; + readonly description: string; /** * "General comment about the emulator (e.g. the systems or game types it * supports)." */ @Expose({ name: 'category' }) - private readonly category?: string; + readonly category?: string; /** * "Version number of the data file. I would recommend using something like a @@ -48,62 +48,56 @@ export default class Header { * be sorted and is unambiguous)." */ @Expose({ name: 'version' }) - private readonly version!: string; + readonly version: string; @Expose({ name: 'date' }) - private readonly date?: string; + readonly date?: string; /** * "Your name and e-mail/web address." */ @Expose({ name: 'author' }) - private readonly author!: string; + readonly author: string; @Expose({ name: 'email' }) - private readonly email?: string; + readonly email?: string; @Expose({ name: 'homepage' }) - private readonly homepage?: string; + readonly homepage?: string; @Expose({ name: 'url' }) - private readonly url?: string; + readonly url?: string; @Expose({ name: 'comment' }) - private readonly comment?: string; + readonly comment?: string; @Type(() => ClrMamePro) @Expose({ name: 'clrmamepro' }) - private readonly clrMamePro?: ClrMamePro; + readonly clrMamePro?: ClrMamePro; @Type(() => RomCenter) @Expose({ name: 'romcenter' }) - private readonly romCenter?: RomCenter; + readonly romCenter?: RomCenter; constructor(options?: HeaderOptions) { - if (options) { - this.name = options.name || ''; - this.description = options.description || ''; - this.category = options.category; - this.version = options.version || ''; - this.date = options.date; - this.author = options.author || ''; - this.email = options.email; - this.homepage = options.homepage; - this.url = options.url; - this.comment = options.comment; - this.clrMamePro = options.clrMamePro; - this.romCenter = options.romCenter; - } + this.name = options?.name || ''; + this.description = options?.description || ''; + this.category = options?.category; + this.version = options?.version || ''; + this.date = options?.date; + this.author = options?.author || ''; + this.email = options?.email; + this.homepage = options?.homepage; + this.url = options?.url; + this.comment = options?.comment; + this.clrMamePro = options?.clrMamePro; + this.romCenter = options?.romCenter; } getName(): string { return this.name; } - getDescription(): string { - return this.description; - } - getVersion(): string { return this.version; } @@ -111,4 +105,8 @@ export default class Header { getDate(): string | undefined { return this.date; } + + getClrMamePro(): ClrMamePro | undefined { + return this.clrMamePro; + } } diff --git a/src/types/logiqx/parent.ts b/src/types/logiqx/parent.ts index f7968c10e..87bb4c950 100644 --- a/src/types/logiqx/parent.ts +++ b/src/types/logiqx/parent.ts @@ -23,18 +23,4 @@ export default class Parent { addChild(child: Game): void { this.games.push(child); } - - // Computed getters - - isBios(): boolean { - return this.getGames().some((game) => game.isBios()); - } - - isRetail(): boolean { - return this.getGames().some((game) => game.isRetail()); - } - - isPrototype(): boolean { - return !this.isRetail() && this.getGames().some((game) => game.isPrototype()); - } } diff --git a/src/types/options.ts b/src/types/options.ts index 371c64c44..f6466eb2b 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -20,6 +20,7 @@ export interface OptionsProps { readonly input?: string[], readonly inputExclude?: string[], readonly output?: string, + readonly header?: string, readonly dirMirror?: boolean, readonly dirDatName?: boolean, readonly dirLetter?: boolean, @@ -63,6 +64,8 @@ export default class Options implements OptionsProps { readonly output!: string; + readonly header!: string; + readonly dirMirror!: boolean; readonly dirDatName!: boolean; @@ -121,14 +124,13 @@ export default class Options implements OptionsProps { readonly help!: boolean; - private tempDir!: string; - constructor(options?: OptionsProps) { this.commands = options?.commands || []; this.dat = options?.dat || []; this.input = options?.input || []; this.inputExclude = options?.inputExclude || []; this.output = options?.output || ''; + this.header = options?.header || ''; this.dirMirror = options?.dirMirror || false; this.dirDatName = options?.dirDatName || false; this.dirLetter = options?.dirLetter || false; @@ -158,39 +160,18 @@ export default class Options implements OptionsProps { this.noBad = options?.noBad || false; this.verbose = options?.verbose || 0; this.help = options?.help || false; - - this.createTempDir(); - this.validate(); } static fromObject(obj: object): Options { return plainToInstance(Options, obj, { enableImplicitConversion: true, - }) - .createTempDir() - .validate(); + }); } toString(): string { return JSON.stringify(instanceToPlain(this)); } - private createTempDir(): Options { - this.tempDir = fsPoly.mkdtempSync(); - process.on('SIGINT', () => { - fsPoly.rmSync(this.tempDir, { - force: true, - recursive: true, - }); - }); - return this; - } - - private validate(): Options { - // TODO(cemmer): validate fields on the class - return this; - } - // Commands private getCommands(): string[] { @@ -211,7 +192,10 @@ export default class Options implements OptionsProps { shouldZip(filePath: string): boolean { return this.getCommands().indexOf('zip') !== -1 - && (!this.getZipExclude() || !micromatch.isMatch(filePath, this.getZipExclude())); + && (!this.getZipExclude() || !micromatch.isMatch( + filePath.replace(/^.[\\/]/, ''), + this.getZipExclude(), + )); } shouldClean(): boolean { @@ -301,7 +285,7 @@ export default class Options implements OptionsProps { } getOutput(dat?: DAT, inputRomPath?: string, romName?: string): string { - let output = this.shouldWrite() ? this.output : this.getTempDir(); + let output = this.shouldWrite() ? this.output : Constants.GLOBAL_TEMP_DIR; if (this.getDirMirror() && inputRomPath) { const mirroredDir = path.dirname(inputRomPath) .replace(/[\\/]/g, path.sep) @@ -337,12 +321,23 @@ export default class Options implements OptionsProps { return path.join( output, `${Constants.COMMAND_NAME}_${moment().format()}.txt` - // Make the filename Windows legal + // Make the filename Windows legal .replace(/:/g, ';') .replace(/[<>:"/\\|?*]/g, '_'), ); } + private getHeader(): string { + return this.header; + } + + shouldReadFileForHeader(filePath: string): boolean { + return this.getHeader().length > 0 && micromatch.isMatch( + filePath.replace(/^.[\\/]/, ''), + this.getHeader(), + ); + } + getDirMirror(): boolean { return this.dirMirror; } @@ -464,10 +459,6 @@ export default class Options implements OptionsProps { return this.help; } - getTempDir(): string { - return this.tempDir; - } - static filterUniqueUpper(array: string[]): string[] { return array .map((value) => value.toUpperCase()) diff --git a/test/console/logger.test.ts b/test/console/logger.test.ts index fcd943f81..b1b6d279a 100644 --- a/test/console/logger.test.ts +++ b/test/console/logger.test.ts @@ -56,7 +56,7 @@ function testLogLevelsAtOrBelow( describe('setLogLevel_getLogLevel', () => { const logLevels = Object.keys(LogLevel).map((ll) => LogLevel[ll as keyof typeof LogLevel]); - test.each(logLevels)('should reflect %s', (logLevel) => { + test.each(logLevels)('should reflect: %s', (logLevel) => { const logger = new Logger(-1, new PassThrough()); expect(logger.getLogLevel()).not.toEqual(logLevel); @@ -66,13 +66,13 @@ describe('setLogLevel_getLogLevel', () => { }); describe('newLine', () => { - testLogLevelsAbove(LogLevel.NEVER - 1)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.NEVER - 1)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().newLine(); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.NEVER - 1)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.NEVER - 1)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().newLine(); await expect(spy.getOutput()).resolves.toEqual('\n'); @@ -80,13 +80,13 @@ describe('newLine', () => { }); describe('debug', () => { - testLogLevelsAbove(LogLevel.DEBUG)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.DEBUG)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().debug('debug message'); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.DEBUG)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.DEBUG)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().debug('debug message'); await expect(spy.getOutput()).resolves.toContain('debug message'); @@ -94,13 +94,13 @@ describe('debug', () => { }); describe('info', () => { - testLogLevelsAbove(LogLevel.INFO)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.INFO)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().info('info message'); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.INFO)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.INFO)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().info('info message'); await expect(spy.getOutput()).resolves.toContain('info message'); @@ -108,13 +108,13 @@ describe('info', () => { }); describe('warn', () => { - testLogLevelsAbove(LogLevel.WARN)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.WARN)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().warn('warn message'); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.WARN)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.WARN)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().warn('warn message'); await expect(spy.getOutput()).resolves.toContain('warn message'); @@ -122,13 +122,13 @@ describe('warn', () => { }); describe('error', () => { - testLogLevelsAbove(LogLevel.ERROR)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.ERROR)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().error('error message'); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.ERROR)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.ERROR)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().error('error message'); await expect(spy.getOutput()).resolves.toContain('error message'); @@ -136,13 +136,13 @@ describe('error', () => { }); describe('printHeader', () => { - testLogLevelsAbove(LogLevel.NEVER - 1)('should not write %s', async (logLevel) => { + testLogLevelsAbove(LogLevel.NEVER - 1)('should not write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().printHeader(); await expect(spy.getOutput()).resolves.toEqual(''); }); - testLogLevelsAtOrBelow(LogLevel.NEVER - 1)('should write %s', async (logLevel) => { + testLogLevelsAtOrBelow(LogLevel.NEVER - 1)('should write: %s', async (logLevel) => { const spy = new LoggerSpy(logLevel); spy.getLogger().printHeader(); await expect(spy.getOutput()).resolves.not.toEqual(''); diff --git a/test/fixtures/roms/7z/fizzbuzz.7z b/test/fixtures/roms/7z/fizzbuzz.7z index 6be6e539d2e61d400729837ead4b50f188a6e435..63c032f4bcc14ff17a32a113940758ddc875e52e 100644 GIT binary patch delta 73 zcmeBY>}OOkuiCR-U50^WZE}hcF9QT5LFwnarTQl-_$%Zwq%sr(#UvR-xEL82Ht(xm Svit_4C>tY#Lc@|q1_l6J1Q3$| delta 73 zcmeBY>}OOkuiCR-U50@ryko;GUIqwAg3{t5YQYl~{1u8A@)>f0Vv-CZT#O71oA*^O SS$>02l#P)=p}6CiuiCR-U50_B?e)Ap+zb$q1f}OMc=&vxf+uSZLmorL#5{QbO0Eq~ delta 49 vcmeBW>}6CiuiCR-U50^0TYA%##NI1*r-_ diff --git a/test/fixtures/roms/fizzbuzz.zip b/test/fixtures/roms/fizzbuzz.zip index 4f2b20b79b71028d08c3248b77de71bcdcc6fd6b..d469d9c233660e740feb7de253737d4d4283f68d 100644 GIT binary patch delta 20 YcmdnaxSer=I&)rX@kFf{Fx9CC07-oZ82|tP delta 20 YcmdnaxSer=I&)Ee?nJE^Fx9CC07@ALDF6Tf diff --git a/test/fixtures/roms/foobar.rom b/test/fixtures/roms/foobar.lnx similarity index 100% rename from test/fixtures/roms/foobar.rom rename to test/fixtures/roms/foobar.lnx diff --git a/test/fixtures/roms/headered/LCDTestROM.lnx.rar b/test/fixtures/roms/headered/LCDTestROM.lnx.rar new file mode 100644 index 0000000000000000000000000000000000000000..82117f8990768837becb4eee7038067888005785 GIT binary patch literal 3562 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/roms/headered/allpads.nes b/test/fixtures/roms/headered/allpads.nes new file mode 100644 index 0000000000000000000000000000000000000000..b35f27775673de2414b1ae2351410f06cee3e874 GIT binary patch literal 32784 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 G+5Za_U8q<9 literal 0 HcmV?d00001 diff --git a/test/fixtures/roms/headered/color_test.nintendoentertainmentsystem b/test/fixtures/roms/headered/color_test.nintendoentertainmentsystem new file mode 100644 index 0000000000000000000000000000000000000000..65d5dc36ea8b7c4b225606e84d216919af9ff2ec GIT binary patch literal 40976 zcmeI1O=ufO6vy96vJwY1-hAL}YT<5+4w93?QYdVxi$t4%Rg_+m^j7d8G)YOJhaS{q zQEX9%P%2|mdMOBkK?MZ@>Bk{;C~OyFVv~dLA@o>sDCx~Kga&HXeY3MqiUlE;hStu1 zwDNCf-gtKAH~ZmPzjyTbiwT2zY4}y=z)i9bEKvUCcgTF%A)CMXLqCyy=oaOR3uG5> zGH;PnJVb8sJ1Uirl3C_@{LS;%`QQfGr;n3)n#(Rska^*I<`0y=@FO`JTOiHT >< zCGL0rx}LDx#YWn06aTnsa)hQDJM8{dkz`akTU3a_2#NbcWhf4YDi8 zr`twjxWuvH()`Ye>%*l&a@jcZ(MspROtO`nmhze|7V0H^iojrUIXC~1y+&}ne1BtliR4-ti?FEdrbc@Qy3`Nbo-BG!zIJY;- zmEv4I%Gq)56_qQzq;kutD6&6_Je*0jjCVN=UTwUNT;@4g=G>iswL zzRtYGnW#oNQ7Tn`oiEKED1E!}%{8u)ZNsQ*&$ zANxj}o>VSz;mK?I6XPWETgylPJd;y(98z1%a}&CXkn$dXxGbjWb-cZvNkUq>V>ur# zkcYMktY2vyfofkfOmhb;i-aBrb-c@3qOR%(5u>?K-Ph9+)9#W76S6B3s$uJaYqHyr zM@+)%os6s65_CwhtdcNTyuEgNk?Y6g-f#*j0(0rw6>ci%GPt+8!UrOu zr=n5+HC6w4uGIJNN{MYkuy58L@d;=89ybDe9%3Y7oH{olwN;JVjz7Zd(tLYcU zXF?2lu5T_SlJb7ydB3%u_xVGx%=GfR+uf98H z&dohmU#{aW`>tPkt_$TkF3j%Uu{+CGYj$*KXf#Xx^Nak}zCJO3&v^b$>O>p*v)z67 zKiB*7>Mo|zlgf3p_j9}Z+V11)pXctnAM1a$f~xh_{qi0F>wm2O)%%k8Jpk7K>U{^^ z|HyfuY&^^NKizdg{2oBo{X$vy3uWCe?5_K<{#XCA*zV8wr;QKmfAzO4*8lkboi^~a Q>jiObVB4H8&-wcQA4tmn*#H0l literal 0 HcmV?d00001 diff --git a/test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z b/test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z new file mode 100644 index 0000000000000000000000000000000000000000..aa818e9c06666b236be232ce4efabdc8ea2a3728 GIT binary patch literal 5414 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 diff --git a/test/fixtures/roms/rar/foobar.rar b/test/fixtures/roms/rar/foobar.rar index 10ae0714b06ba79005c9d233610bf12230944dc8..17692f1f4acb093529670238281430e60b117246 100644 GIT binary patch delta 28 jcmebAnIOVsG-;xks$foDMOuD-QeqL;5!-482X+Pkf1C(- delta 28 jcmebAnIOXS=;TB(Rl%bC+_e1sq{JewBevBH4(tp7llKW* diff --git a/test/fixtures/roms/raw/fizzbuzz.rom b/test/fixtures/roms/raw/fizzbuzz.nes similarity index 100% rename from test/fixtures/roms/raw/fizzbuzz.rom rename to test/fixtures/roms/raw/fizzbuzz.nes diff --git a/test/fixtures/roms/raw/foobar.rom b/test/fixtures/roms/raw/foobar.lnx similarity index 100% rename from test/fixtures/roms/raw/foobar.rom rename to test/fixtures/roms/raw/foobar.lnx diff --git a/test/fixtures/roms/zip/fizzbuzz.zip b/test/fixtures/roms/zip/fizzbuzz.zip index 4f2b20b79b71028d08c3248b77de71bcdcc6fd6b..d469d9c233660e740feb7de253737d4d4283f68d 100644 GIT binary patch delta 20 YcmdnaxSer=I&)rX@kFf{Fx9CC07-oZ82|tP delta 20 YcmdnaxSer=I&)Ee?nJE^Fx9CC07@ALDF6Tf diff --git a/test/fixtures/roms/zip/foobar.zip b/test/fixtures/roms/zip/foobar.zip index 23e77cdc525082f8902174694a8bbc5973dffc6a..d951ee6cc85608ee77af70d38d2b813f0fabd52c 100644 GIT binary patch delta 84 zcmX@lxSvrYz?+#xgn@y9gQ0XKQ>goNolTrT9y<_oF(@#k<>x0Q7U|{WRZN_zAmn}e gyuX*uBPNCbZ$>5&W}vo-Svt&MNdvY(pcV!O06<3*y8r+H delta 100 zcmdnbc%D%sz?+#xgnc~WKuQC=8JR?wfyPXX(qRTGHegEx8wCLUyA)&q diff --git a/test/igir.test.ts b/test/igir.test.ts index 58daa678f..a289cda8c 100644 --- a/test/igir.test.ts +++ b/test/igir.test.ts @@ -1,28 +1,31 @@ import { jest } from '@jest/globals'; +import fg from 'fast-glob'; import fs from 'fs'; import path from 'path'; import Logger from '../src/console/logger.js'; import LogLevel from '../src/console/logLevel.js'; +import Constants from '../src/constants.js'; import Igir from '../src/igir.js'; import fsPoly from '../src/polyfill/fsPoly.js'; import Options, { OptionsProps } from '../src/types/options.js'; const LOGGER = new Logger(LogLevel.NEVER); -async function expectEndToEnd(options: OptionsProps, expectedFiles: string[]): Promise { +async function expectEndToEnd(optionsProps: OptionsProps, expectedFiles: string[]): Promise { const tempInput = fsPoly.mkdtempSync(); fsPoly.copyDirSync('./test/fixtures', tempInput); const tempOutput = fsPoly.mkdtempSync(); - await new Igir(new Options({ + const options = new Options({ dat: [path.join(tempInput, 'dats', '*')], input: [path.join(tempInput, 'roms', '**', '*')], - ...options, + ...optionsProps, output: tempOutput, verbose: Number.MAX_SAFE_INTEGER, - }), LOGGER).main(); + }); + await new Igir(options, LOGGER).main(); const writtenRoms = fs.readdirSync(tempOutput); @@ -34,6 +37,12 @@ async function expectEndToEnd(options: OptionsProps, expectedFiles: string[]): P fsPoly.rmSync(tempInput, { recursive: true }); fsPoly.rmSync(tempOutput, { recursive: true }); + + const reports = await fg(path.join( + path.dirname(options.getOutputReport()), + `${Constants.COMMAND_NAME}_*.txt`, + )); + reports.forEach((report) => fsPoly.rmSync(report)); } jest.setTimeout(10_000); @@ -80,7 +89,6 @@ it('should copy and clean', async () => { }); it('should report without copy', async () => { - // TODO(cemmer): cleanup the written report await expectEndToEnd({ commands: ['report'], }, []); diff --git a/test/modules/argumentsParser.test.ts b/test/modules/argumentsParser.test.ts index 20ea596b0..fca9e77e0 100644 --- a/test/modules/argumentsParser.test.ts +++ b/test/modules/argumentsParser.test.ts @@ -131,6 +131,13 @@ describe('options', () => { expect(argumentsParser.parse(['copy', '--input', os.devNull, '--output', 'foo', '--output', 'bar']).getOutput()).toEqual('bar'); }); + it('should parse "header"', () => { + expect(() => argumentsParser.parse([...dummyCommandAndRequiredArgs, '-H'])).toThrow(/not enough arguments/i); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '-H', '**/*']).shouldReadFileForHeader('file.rom')).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--header', '**/*']).shouldReadFileForHeader('file.rom')).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--header', '**/*', '--header', 'nope']).shouldReadFileForHeader('file.rom')).toEqual(false); + }); + it('should parse "dir-mirror"', () => { expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-mirror']).getDirMirror()).toEqual(true); expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-mirror', 'true']).getDirMirror()).toEqual(true); @@ -391,5 +398,6 @@ describe('options', () => { it('should parse "help"', () => { expect(argumentsParser.parse(['-h']).getHelp()).toEqual(true); expect(argumentsParser.parse(['--help']).getHelp()).toEqual(true); + expect(argumentsParser.parse(['--help', '100']).getHelp()).toEqual(true); }); }); diff --git a/test/modules/candidateFilter.test.ts b/test/modules/candidateFilter.test.ts index c10d98014..78533f23a 100644 --- a/test/modules/candidateFilter.test.ts +++ b/test/modules/candidateFilter.test.ts @@ -9,8 +9,8 @@ import Options, { OptionsProps } from '../../src/types/options.js'; import ReleaseCandidate from '../../src/types/releaseCandidate.js'; import ProgressBarFake from '../console/progressBarFake.js'; -function buildCandidateFilter(options: object = {}): CandidateFilter { - return new CandidateFilter(Options.fromObject(options), new ProgressBarFake()); +function buildCandidateFilter(options: OptionsProps = {}): CandidateFilter { + return new CandidateFilter(new Options(options), new ProgressBarFake()); } async function expectFilteredCandidates( diff --git a/test/modules/candidateGenerator.test.ts b/test/modules/candidateGenerator.test.ts index 48c2f86e2..5f97b6e5c 100644 --- a/test/modules/candidateGenerator.test.ts +++ b/test/modules/candidateGenerator.test.ts @@ -1,7 +1,7 @@ import CandidateGenerator from '../../src/modules/candidateGenerator.js'; +import Zip from '../../src/types/archives/zip.js'; import ArchiveEntry from '../../src/types/files/archiveEntry.js'; import File from '../../src/types/files/file.js'; -import Zip from '../../src/types/files/zip.js'; import DAT from '../../src/types/logiqx/dat.js'; import Game from '../../src/types/logiqx/game.js'; import Header from '../../src/types/logiqx/header.js'; diff --git a/test/modules/datScanner.test.ts b/test/modules/datScanner.test.ts index 9649db9b9..215605a03 100644 --- a/test/modules/datScanner.test.ts +++ b/test/modules/datScanner.test.ts @@ -8,7 +8,7 @@ import ProgressBarFake from '../console/progressBarFake.js'; jest.setTimeout(10_000); function createDatScanner(dat: string[]): DATScanner { - return new DATScanner(Options.fromObject({ dat }), new ProgressBarFake()); + return new DATScanner(new Options({ dat }), new ProgressBarFake()); } it('should throw on nonexistent paths', async () => { diff --git a/test/modules/headerProcessor.test.ts b/test/modules/headerProcessor.test.ts new file mode 100644 index 000000000..74f4cdf92 --- /dev/null +++ b/test/modules/headerProcessor.test.ts @@ -0,0 +1,76 @@ +import HeaderProcessor from '../../src/modules/headerProcessor.js'; +import ROMScanner from '../../src/modules/romScanner.js'; +import Options from '../../src/types/options.js'; +import ProgressBarFake from '../console/progressBarFake.js'; + +describe('extension has possible header', () => { + it('should do nothing if extension not found', async () => { + const inputRomFiles = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/{,**/}*.rom'], + }), new ProgressBarFake()).scan(); + expect(inputRomFiles.length).toBeGreaterThan(0); + + const processedRomFiles = await new HeaderProcessor(new Options(), new ProgressBarFake()) + .process(inputRomFiles); + + expect(processedRomFiles).toHaveLength(inputRomFiles.length); + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < processedRomFiles.length; i += 1) { + await expect(inputRomFiles[i].equals(processedRomFiles[i])).resolves.toEqual(true); + } + }); + + it('should process headered files', async () => { + const inputRomFiles = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/headered/*{.a78,.lnx,.nes,.fds}*'], + }), new ProgressBarFake()).scan(); + expect(inputRomFiles.length).toBeGreaterThan(0); + + const processedRomFiles = await new HeaderProcessor(new Options(), new ProgressBarFake()) + .process(inputRomFiles); + + expect(processedRomFiles).toHaveLength(inputRomFiles.length); + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < processedRomFiles.length; i += 1) { + // CRC should have changed + await expect(inputRomFiles[i].equals(processedRomFiles[i])).resolves.toEqual(false); + } + }); +}); + +describe('should read file for header', () => { + it('should do nothing with un-headered files', async () => { + const inputRomFiles = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/!(headered){,/}*'], + }), new ProgressBarFake()).scan(); + expect(inputRomFiles.length).toBeGreaterThan(0); + + const processedRomFiles = await new HeaderProcessor(new Options({ + header: '**/*', + }), new ProgressBarFake()).process(inputRomFiles); + + expect(processedRomFiles).toHaveLength(inputRomFiles.length); + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < processedRomFiles.length; i += 1) { + await expect(inputRomFiles[i].equals(processedRomFiles[i])).resolves.toEqual(true); + } + }); + + it('should process headered files', async () => { + const inputRomFiles = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/headered/!(*{.a78,.lnx,.nes,.fds}*)'], + }), new ProgressBarFake()).scan(); + expect(inputRomFiles.length).toBeGreaterThan(0); + + const processedRomFiles = await new HeaderProcessor(new Options({ + header: '**/*', + }), new ProgressBarFake()).process(inputRomFiles); + + expect(processedRomFiles).toHaveLength(inputRomFiles.length); + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < processedRomFiles.length; i += 1) { + // CRC should have changed + await expect(inputRomFiles[i].equals(processedRomFiles[i])).resolves.toEqual(false); + } + }); +}); diff --git a/test/modules/outputCleaner.test.ts b/test/modules/outputCleaner.test.ts index ee6b86191..a1d91f5fa 100644 --- a/test/modules/outputCleaner.test.ts +++ b/test/modules/outputCleaner.test.ts @@ -9,10 +9,12 @@ import ProgressBarFake from '../console/progressBarFake.js'; jest.setTimeout(10_000); +const romFixtures = path.join('test', 'fixtures', 'roms'); + async function runOutputCleaner(writtenFilePathsToExclude: string[]): Promise { // Copy the fixture files to a temp directory const tempDir = fsPoly.mkdtempSync(); - fsPoly.copyDirSync('./test/fixtures', tempDir); + fsPoly.copyDirSync(romFixtures, tempDir); const writtenRomFilesToExclude = writtenFilePathsToExclude .map((filePath) => new File(path.join(tempDir, filePath), '00000000')); @@ -38,30 +40,32 @@ async function runOutputCleaner(writtenFilePathsToExclude: string[]): Promise { - const existingFiles = fsPoly.walkSync('./test/fixtures') - .map((filePath) => filePath.replace(/^test[\\/]fixtures[\\/]/, '')); + const existingFiles = fsPoly.walkSync(romFixtures) + .map((filePath) => filePath.replace(/^test[\\/]fixtures[\\/]roms[\\/]/, '')) + .sort(); const filesRemaining = await runOutputCleaner([]); expect(filesRemaining).toEqual(existingFiles); }); it('should delete nothing if all match', async () => { - const existingFiles = fsPoly.walkSync('./test/fixtures') - .map((filePath) => filePath.replace(/^test[\\/]fixtures[\\/]/, '')); + const existingFiles = fsPoly.walkSync(romFixtures) + .map((filePath) => filePath.replace(/^test[\\/]fixtures[\\/]roms[\\/]/, '')) + .sort(); const filesRemaining = await runOutputCleaner(existingFiles); expect(filesRemaining).toEqual(existingFiles); }); it('should delete some if some matched', async () => { const filesRemaining = await runOutputCleaner([ - path.join('roms', '7z', 'empty.7z'), - path.join('roms', 'raw', 'fizzbuzz.rom'), - path.join('roms', 'zip', 'foobar.zip'), + path.join('7z', 'empty.7z'), + path.join('raw', 'fizzbuzz.nes'), + path.join('zip', 'foobar.zip'), 'non-existent file', ]); expect(filesRemaining).toEqual([ - path.join('roms', '7z', 'empty.7z'), - path.join('roms', 'raw', 'fizzbuzz.rom'), - path.join('roms', 'zip', 'foobar.zip'), + path.join('7z', 'empty.7z'), + path.join('raw', 'fizzbuzz.nes'), + path.join('zip', 'foobar.zip'), ]); }); diff --git a/test/modules/reportGenerator.test.ts b/test/modules/reportGenerator.test.ts new file mode 100644 index 000000000..d28a79909 --- /dev/null +++ b/test/modules/reportGenerator.test.ts @@ -0,0 +1,2 @@ +// TODO(cemmer) +it('ok', () => {}); diff --git a/test/modules/romScanner.test.ts b/test/modules/romScanner.test.ts index da8a820bf..ef047d0cf 100644 --- a/test/modules/romScanner.test.ts +++ b/test/modules/romScanner.test.ts @@ -8,7 +8,7 @@ import ProgressBarFake from '../console/progressBarFake.js'; jest.setTimeout(10_000); function createRomScanner(input: string[], inputExclude: string[] = []): ROMScanner { - return new ROMScanner(Options.fromObject({ input, inputExclude }), new ProgressBarFake()); + return new ROMScanner(new Options({ input, inputExclude }), new ProgressBarFake()); } it('should throw on nonexistent paths', async () => { @@ -25,22 +25,33 @@ it('should return empty list on no results', async () => { await expect(createRomScanner([os.devNull]).scan()).resolves.toEqual([]); }); -it('should return empty list when input matches inputExclude', async () => { - // TODO(cemmer) -}); - it('should not throw on bad archives', async () => { await expect(createRomScanner(['test/fixtures/roms/**/invalid.zip']).scan()).resolves.toHaveLength(0); await expect(createRomScanner(['test/fixtures/roms/**/invalid.rar']).scan()).resolves.toHaveLength(0); await expect(createRomScanner(['test/fixtures/roms/**/invalid.7z']).scan()).resolves.toHaveLength(0); }); -it('should scan multiple files', async () => { - const expectedRomFiles = 22; - await expect(createRomScanner(['test/fixtures/roms']).scan()).resolves.toHaveLength(expectedRomFiles); - await expect(createRomScanner(['test/fixtures/roms/*', 'test/fixtures/roms/**/*.{rom,zip,rar,7z}']).scan()).resolves.toHaveLength(expectedRomFiles); - await expect(createRomScanner(['test/fixtures/roms/**/*.{rom,zip,rar,7z}']).scan()).resolves.toHaveLength(expectedRomFiles); - await expect(createRomScanner(['test/fixtures/roms/**/*.{rom,zip,rar,7z}', 'test/fixtures/roms/**/*.{rom,zip,rar,7z}']).scan()).resolves.toHaveLength(expectedRomFiles); +describe('multiple files', () => { + it('no files are excluded', async () => { + const expectedRomFiles = 27; + await expect(createRomScanner(['test/fixtures/roms']).scan()).resolves.toHaveLength(expectedRomFiles); + await expect(createRomScanner(['test/fixtures/roms/*', 'test/fixtures/roms/**/*']).scan()).resolves.toHaveLength(expectedRomFiles); + await expect(createRomScanner(['test/fixtures/roms/**/*']).scan()).resolves.toHaveLength(expectedRomFiles); + await expect(createRomScanner(['test/fixtures/roms/**/*', 'test/fixtures/roms/**/*.{rom,zip}']).scan()).resolves.toHaveLength(expectedRomFiles); + }); + + it('some files are excluded', async () => { + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*.rom']).scan()).resolves.toHaveLength(23); + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*.rom', 'test/fixtures/roms/**/*.rom']).scan()).resolves.toHaveLength(23); + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*.rom', 'test/fixtures/roms/**/*.zip']).scan()).resolves.toHaveLength(17); + }); + + it('all files are excluded', async () => { + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*']).scan()).resolves.toEqual([]); + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*', 'test/fixtures/roms/**/*']).scan()).resolves.toEqual([]); + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/*', 'test/fixtures/roms/*/**/*']).scan()).resolves.toEqual([]); + await expect(createRomScanner(['test/fixtures/roms/**/*'], ['test/fixtures/roms/**/*.zip', 'test/fixtures/roms/**/*']).scan()).resolves.toEqual([]); + }); }); it('should scan single files', async () => { diff --git a/test/modules/romWriter.test.ts b/test/modules/romWriter.test.ts index 92b967401..f7de9178b 100644 --- a/test/modules/romWriter.test.ts +++ b/test/modules/romWriter.test.ts @@ -6,7 +6,6 @@ import path from 'path'; import ROMScanner from '../../src/modules/romScanner.js'; import ROMWriter from '../../src/modules/romWriter.js'; import fsPoly from '../../src/polyfill/fsPoly.js'; -import ArchiveEntry from '../../src/types/files/archiveEntry.js'; import File from '../../src/types/files/file.js'; import DAT from '../../src/types/logiqx/dat.js'; import Game from '../../src/types/logiqx/game.js'; @@ -71,10 +70,7 @@ async function indexFilesByName( const releaseCandidates = await Promise.all(romFiles .map(async (romFile) => { const release = new Release(romName, 'UNK', undefined); - let romFileName = romFile.getFilePath(); - if (romFile instanceof ArchiveEntry) { - romFileName = (romFile).getEntryPath(); - } + const romFileName = romFile.getExtractedFilePath(); const rom = new ROM( path.basename(romFileName), await romFile.getCrc32(), @@ -176,7 +172,7 @@ describe('zip', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); // Write once const firstWrittenPaths = await runRomWriter(outputTemp, { @@ -213,7 +209,7 @@ describe('zip', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); // Write once const firstWrittenPaths = await runRomWriter(outputTemp, { @@ -245,7 +241,7 @@ describe('zip', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); const existingZip = new AdmZip(); existingZip.addFile('something.rom', Buffer.from('something')); @@ -275,7 +271,7 @@ describe('zip', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); const writtenPaths = await runRomWriter(outputTemp, { commands: ['copy', 'zip', 'test'], @@ -403,8 +399,8 @@ describe('raw', () => { }, parentsToCandidates); expect(writtenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -420,7 +416,7 @@ describe('raw', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); // Write once const firstWrittenPaths = await runRomWriter(outputTemp, { @@ -428,8 +424,8 @@ describe('raw', () => { }, parentsToCandidates); expect(firstWrittenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -440,8 +436,8 @@ describe('raw', () => { }, parentsToCandidates); expect(secondWrittenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -457,7 +453,7 @@ describe('raw', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); // Write once const firstWrittenPaths = await runRomWriter(outputTemp, { @@ -465,8 +461,8 @@ describe('raw', () => { }, parentsToCandidates); expect(firstWrittenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -489,15 +485,15 @@ describe('raw', () => { const inputFiles = fsPoly.walkSync(inputTemp); expect(inputFiles.length).toBeGreaterThan(0); - const parentsToCandidates = await indexFilesByName(inputTemp, '**/*'); + const parentsToCandidates = await indexFilesByName(inputTemp, '**/!(headered)/*'); const writtenPaths = await runRomWriter(outputTemp, { commands: ['copy', 'test'], }, parentsToCandidates); expect(writtenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -519,8 +515,8 @@ describe('raw', () => { commands: ['copy', 'test'], }, parentsToCandidates); expect(writtenPaths).toEqual([ - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -543,8 +539,8 @@ describe('raw', () => { }, parentsToCandidates); expect(writtenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -566,8 +562,8 @@ describe('raw', () => { commands: ['copy', 'test'], }, parentsToCandidates); expect(writtenPaths).toEqual([ - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); @@ -591,8 +587,8 @@ describe('raw', () => { }, parentsToCandidates); expect(writtenPaths).toEqual([ 'empty.rom', - 'fizzbuzz.rom', - 'foobar.rom', + 'fizzbuzz.nes', + 'foobar.lnx', 'loremipsum.rom', 'unknown.rom', ]); diff --git a/test/modules/statusGenerator.test.ts b/test/modules/statusGenerator.test.ts new file mode 100644 index 000000000..d28a79909 --- /dev/null +++ b/test/modules/statusGenerator.test.ts @@ -0,0 +1,2 @@ +// TODO(cemmer) +it('ok', () => {}); diff --git a/test/types/files/archive.test.ts b/test/types/files/archive.test.ts index 1bba0c38e..06b3b1685 100644 --- a/test/types/files/archive.test.ts +++ b/test/types/files/archive.test.ts @@ -1,16 +1,16 @@ -import ArchiveFactory from '../../../src/types/files/archiveFactory.js'; +import ArchiveFactory from '../../../src/types/archives/archiveFactory.js'; describe('getArchiveEntries', () => { // TODO(cemmer): fixture archives with multiple entries test.each([ // fizzbuzz - ['./test/fixtures/roms/7z/fizzbuzz.7z', 'fizzbuzz.rom', '370517b5'], - ['./test/fixtures/roms/rar/fizzbuzz.rar', 'fizzbuzz.rom', '370517b5'], - ['./test/fixtures/roms/zip/fizzbuzz.zip', 'fizzbuzz.rom', '370517b5'], + ['./test/fixtures/roms/7z/fizzbuzz.7z', 'fizzbuzz.nes', '370517b5'], + ['./test/fixtures/roms/rar/fizzbuzz.rar', 'fizzbuzz.nes', '370517b5'], + ['./test/fixtures/roms/zip/fizzbuzz.zip', 'fizzbuzz.nes', '370517b5'], // foobar - ['./test/fixtures/roms/7z/foobar.7z', 'foobar.rom', 'b22c9747'], - ['./test/fixtures/roms/rar/foobar.rar', 'foobar.rom', 'b22c9747'], - ['./test/fixtures/roms/zip/foobar.zip', 'foobar.rom', 'b22c9747'], + ['./test/fixtures/roms/7z/foobar.7z', 'foobar.lnx', 'b22c9747'], + ['./test/fixtures/roms/rar/foobar.rar', 'foobar.lnx', 'b22c9747'], + ['./test/fixtures/roms/zip/foobar.zip', 'foobar.lnx', 'b22c9747'], // loremipsum ['./test/fixtures/roms/7z/loremipsum.7z', 'loremipsum.rom', '70856527'], ['./test/fixtures/roms/rar/loremipsum.rar', 'loremipsum.rom', '70856527'], @@ -19,7 +19,7 @@ describe('getArchiveEntries', () => { ['./test/fixtures/roms/7z/unknown.7z', 'unknown.rom', '377a7727'], ['./test/fixtures/roms/rar/unknown.rar', 'unknown.rom', '377a7727'], ['./test/fixtures/roms/zip/unknown.zip', 'unknown.rom', '377a7727'], - ])('should enumerate the single file archive %s', async (filePath, expectedEntryPath, expectedCrc) => { + ])('should enumerate the single file archive: %s', async (filePath, expectedEntryPath, expectedCrc) => { const archive = ArchiveFactory.archiveFrom(filePath); const entries = await archive.getArchiveEntries(); diff --git a/test/types/files/archiveEntry.test.ts b/test/types/files/archiveEntry.test.ts index d9b8db372..f4ce4b57e 100644 --- a/test/types/files/archiveEntry.test.ts +++ b/test/types/files/archiveEntry.test.ts @@ -2,10 +2,11 @@ import fs from 'fs'; import ROMScanner from '../../../src/modules/romScanner.js'; import fsPoly from '../../../src/polyfill/fsPoly.js'; +import ArchiveFactory from '../../../src/types/archives/archiveFactory.js'; +import SevenZip from '../../../src/types/archives/sevenZip.js'; +import Zip from '../../../src/types/archives/zip.js'; import ArchiveEntry from '../../../src/types/files/archiveEntry.js'; -import ArchiveFactory from '../../../src/types/files/archiveFactory.js'; -import SevenZip from '../../../src/types/files/sevenZip.js'; -import Zip from '../../../src/types/files/zip.js'; +import FileHeader from '../../../src/types/files/fileHeader.js'; import Options from '../../../src/types/options.js'; import ProgressBarFake from '../../console/progressBarFake.js'; @@ -20,59 +21,111 @@ describe('getEntryPath', () => { }); }); -describe('extract', () => { - it('should extract zip files', async () => { - // Note: this will only return valid zips with at least one file - const zips = await new ROMScanner(new Options({ - input: ['./test/fixtures/roms/zip'], - }), new ProgressBarFake()).scan(); - expect(zips).toHaveLength(4); +describe('getCrc32', () => { + test.each([ + ['./test/fixtures/roms/7z/fizzbuzz.7z', '370517b5'], + ['./test/fixtures/roms/rar/fizzbuzz.rar', '370517b5'], + ['./test/fixtures/roms/zip/fizzbuzz.zip', '370517b5'], + ['./test/fixtures/roms/7z/foobar.7z', 'b22c9747'], + ['./test/fixtures/roms/rar/foobar.rar', 'b22c9747'], + ['./test/fixtures/roms/zip/foobar.zip', 'b22c9747'], + ['./test/fixtures/roms/7z/loremipsum.7z', '70856527'], + ['./test/fixtures/roms/rar/loremipsum.rar', '70856527'], + ['./test/fixtures/roms/zip/loremipsum.zip', '70856527'], + ['./test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z', 'f6cc9b1c'], + ['./test/fixtures/roms/headered/fds_joypad_test.fds.zip', '1e58456d'], + ['./test/fixtures/roms/headered/LCDTestROM.lnx.rar', '2d251538'], + ])('should hash the full archive entry: %s', async (filePath, expectedCrc) => { + const archive = ArchiveFactory.archiveFrom(filePath); - const temp = fsPoly.mkdtempSync(); - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < zips.length; i += 1) { - const zip = zips[i]; - await zip.extract((localFile) => { - expect(fs.existsSync(localFile)).toEqual(true); - expect(localFile).not.toEqual(zip.getFilePath()); - }); - } - fsPoly.rmSync(temp, { recursive: true }); + const archiveEntries = await archive.getArchiveEntries(); + expect(archiveEntries).toHaveLength(1); + const archiveEntry = archiveEntries[0]; + + await expect(archiveEntry.getCrc32()).resolves.toEqual(expectedCrc); }); +}); - it('should extract rar files', async () => { - // Note: this will only return valid rars with at least one file - const rars = await new ROMScanner(new Options({ - input: ['./test/fixtures/roms/rar'], - }), new ProgressBarFake()).scan(); - expect(rars).toHaveLength(4); +describe('getCrc32WithoutHeader', () => { + test.each([ + ['./test/fixtures/roms/7z/fizzbuzz.7z', '370517b5'], + ['./test/fixtures/roms/rar/fizzbuzz.rar', '370517b5'], + ['./test/fixtures/roms/zip/fizzbuzz.zip', '370517b5'], + ['./test/fixtures/roms/7z/foobar.7z', 'b22c9747'], + ['./test/fixtures/roms/rar/foobar.rar', 'b22c9747'], + ['./test/fixtures/roms/zip/foobar.zip', 'b22c9747'], + ['./test/fixtures/roms/7z/loremipsum.7z', '70856527'], + ['./test/fixtures/roms/rar/loremipsum.rar', '70856527'], + ['./test/fixtures/roms/zip/loremipsum.zip', '70856527'], + ['./test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z', 'f6cc9b1c'], + ['./test/fixtures/roms/headered/fds_joypad_test.fds.zip', '1e58456d'], + ['./test/fixtures/roms/headered/LCDTestROM.lnx.rar', '2d251538'], + ])('should hash the full archive entry when no header given: %s', async (filePath, expectedCrc) => { + const archive = ArchiveFactory.archiveFrom(filePath); - const temp = fsPoly.mkdtempSync(); - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < rars.length; i += 1) { - const rar = rars[i]; - await rar.extract((localFile) => { - expect(fs.existsSync(localFile)).toEqual(true); - expect(localFile).not.toEqual(rar.getFilePath()); - }); - } - fsPoly.rmSync(temp, { recursive: true }); + const archiveEntries = await archive.getArchiveEntries(); + expect(archiveEntries).toHaveLength(1); + const archiveEntry = archiveEntries[0]; + + await expect(archiveEntry.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); + }); + + test.each([ + ['./test/fixtures/roms/7z/fizzbuzz.7z', '370517b5'], + ['./test/fixtures/roms/rar/fizzbuzz.rar', '370517b5'], + ['./test/fixtures/roms/zip/fizzbuzz.zip', '370517b5'], + ['./test/fixtures/roms/7z/foobar.7z', 'b22c9747'], + ['./test/fixtures/roms/rar/foobar.rar', 'b22c9747'], + ['./test/fixtures/roms/zip/foobar.zip', 'b22c9747'], + ])('should hash the full archive entry when header is given but not present in file: %s', async (filePath, expectedCrc) => { + const archive = ArchiveFactory.archiveFrom(filePath); + + const archiveEntries = await archive.getArchiveEntries(); + expect(archiveEntries).toHaveLength(1); + const archiveEntry = archiveEntries[0].withFileHeader( + FileHeader.getForFilename(archiveEntries[0].getExtractedFilePath()) as FileHeader, + ); + + await expect(archiveEntry.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); }); - it('should extract 7z files', async () => { - // Note: this will only return valid 7z's with at least one file - const sevenZips = await new ROMScanner(new Options({ - input: ['./test/fixtures/roms/7z'], + test.each([ + ['./test/fixtures/roms/headered/diagnostic_test_cartridge.a78.7z', 'a1eaa7c1'], + ['./test/fixtures/roms/headered/fds_joypad_test.fds.zip', '3ecbac61'], + ['./test/fixtures/roms/headered/LCDTestROM.lnx.rar', '42583855'], + ])('should hash the archive entry without the header when header is given and present in file: %s', async (filePath, expectedCrc) => { + const archive = ArchiveFactory.archiveFrom(filePath); + + const archiveEntries = await archive.getArchiveEntries(); + expect(archiveEntries).toHaveLength(1); + const archiveEntry = archiveEntries[0].withFileHeader( + FileHeader.getForFilename(archiveEntries[0].getExtractedFilePath()) as FileHeader, + ); + + await expect(archiveEntry.getCrc32()).resolves.not.toEqual(expectedCrc); + await expect(archiveEntry.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); + }); +}); + +describe('extract', () => { + it('should extract archived files', async () => { + // Note: this will only return valid archives with at least one file + const archiveEntries = await new ROMScanner(new Options({ + input: [ + './test/fixtures/roms/zip', + './test/fixtures/roms/rar', + './test/fixtures/roms/7z', + ], }), new ProgressBarFake()).scan(); - expect(sevenZips).toHaveLength(4); + expect(archiveEntries).toHaveLength(12); const temp = fsPoly.mkdtempSync(); /* eslint-disable no-await-in-loop */ - for (let i = 0; i < sevenZips.length; i += 1) { - const sevenZip = sevenZips[i]; - await sevenZip.extract((localFile) => { + for (let i = 0; i < archiveEntries.length; i += 1) { + const zip = archiveEntries[i]; + await zip.extract((localFile) => { expect(fs.existsSync(localFile)).toEqual(true); - expect(localFile).not.toEqual(sevenZip.getFilePath()); + expect(localFile).not.toEqual(zip.getFilePath()); }); } fsPoly.rmSync(temp, { recursive: true }); diff --git a/test/types/files/file.test.ts b/test/types/files/file.test.ts index be8adf9bb..245d029cb 100644 --- a/test/types/files/file.test.ts +++ b/test/types/files/file.test.ts @@ -2,8 +2,8 @@ import fs from 'fs'; import ROMScanner from '../../../src/modules/romScanner.js'; import fsPoly from '../../../src/polyfill/fsPoly.js'; -import ArchiveFactory from '../../../src/types/files/archiveFactory.js'; import File from '../../../src/types/files/file.js'; +import FileHeader from '../../../src/types/files/fileHeader.js'; import Options from '../../../src/types/options.js'; import ProgressBarFake from '../../console/progressBarFake.js'; @@ -28,21 +28,47 @@ describe('getCrc32', () => { test.each([ ['./test/fixtures/roms/raw/empty.rom', '00000000'], - ['./test/fixtures/roms/raw/fizzbuzz.rom', '370517b5'], - ['./test/fixtures/roms/raw/foobar.rom', 'b22c9747'], + ['./test/fixtures/roms/raw/fizzbuzz.nes', '370517b5'], + ['./test/fixtures/roms/raw/foobar.lnx', 'b22c9747'], ['./test/fixtures/roms/raw/loremipsum.rom', '70856527'], - ])('should hash the file path: %s', async (filePath, expectedCrc) => { + ])('should hash the full file: %s', async (filePath, expectedCrc) => { const file = new File(filePath); await expect(file.getCrc32()).resolves.toEqual(expectedCrc); }); }); +describe('getCrc32WithoutHeader', () => { + test.each([ + ['./test/fixtures/roms/headered/allpads.nes', '9180a163'], + ])('should hash the full file when no header given: %s', async (filePath, expectedCrc) => { + const file = new File(filePath); + await expect(file.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); + }); + + test.each([ + ['./test/fixtures/roms/raw/fizzbuzz.nes', '370517b5'], + ['./test/fixtures/roms/raw/foobar.lnx', 'b22c9747'], + ])('should hash the full file when header is given but not present in file: %s', async (filePath, expectedCrc) => { + const file = new File(filePath) + .withFileHeader(FileHeader.getForFilename(filePath) as FileHeader); + await expect(file.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); + }); + + test.each([ + ['./test/fixtures/roms/headered/allpads.nes', '6339abe6'], + ])('should hash the full file when header is given and present in file: %s', async (filePath, expectedCrc) => { + const file = new File(filePath) + .withFileHeader(FileHeader.getForFilename(filePath) as FileHeader); + await expect(file.getCrc32WithoutHeader()).resolves.toEqual(expectedCrc); + }); +}); + describe('isZip', () => { test.each([ './test/fixtures/roms/zip/empty.zip', './test/fixtures/roms/fizzbuzz.zip', ])('should return true when appropriate', (filePath) => { - const file = ArchiveFactory.archiveFrom(filePath); + const file = new File(filePath); expect(file.isZip()).toEqual(true); }); @@ -52,7 +78,7 @@ describe('isZip', () => { './test/fixtures/roms/rar/fizzbuzz.rar', './test/fixtures/roms/unknown.rar', ])('should return false when appropriate', (filePath) => { - const file = ArchiveFactory.archiveFrom(filePath); + const file = new File(filePath); expect(file.isZip()).toEqual(false); }); }); diff --git a/test/types/files/fileHeader.test.ts b/test/types/files/fileHeader.test.ts new file mode 100644 index 000000000..c2e3e9bfb --- /dev/null +++ b/test/types/files/fileHeader.test.ts @@ -0,0 +1,81 @@ +import ROMScanner from '../../../src/modules/romScanner.js'; +import FileHeader from '../../../src/types/files/fileHeader.js'; +import Options from '../../../src/types/options.js'; +import ProgressBarFake from '../../console/progressBarFake.js'; + +describe('getForName', () => { + test.each([ + 'No-Intro_A7800.xml', + 'No-Intro_LNX.xml', + 'No-Intro_NES.xml', + 'No-Intro_FDS.xml', + ])('should get a file header for name: %s', (headerName) => { + const fileHeader = FileHeader.getForName(headerName); + expect(fileHeader).not.toBeUndefined(); + }); + + test.each([ + '', + ' ', + '🤷', + ])('should not get a file header for name: %s', (headerName) => { + const fileHeader = FileHeader.getForName(headerName); + expect(fileHeader).toBeUndefined(); + }); +}); + +describe('getForExtension', () => { + test.each([ + 'rom.a78', + 'rom.lnx', + 'rom.nes', + 'rom.fds', + 'rom.zip.fds', + ])('should get a file header for extension: %s', (filePath) => { + const fileHeader = FileHeader.getForFilename(filePath); + expect(fileHeader).not.toBeUndefined(); + }); + + test.each([ + '', + ' ', + '.nes', + 'rom.zip', + 'rom.nes.zip', + ])('should not get a file header for extension: %s', (filePath) => { + const fileHeader = FileHeader.getForFilename(filePath); + expect(fileHeader).toBeUndefined(); + }); +}); + +describe('getForFile', () => { + it('should get a file header for headered files', async () => { + const headeredRoms = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/headered'], + }), new ProgressBarFake()).scan(); + expect(headeredRoms).toHaveLength(5); + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < headeredRoms.length; i += 1) { + await headeredRoms[i].extract(async (localFile) => { + const fileHeader = await FileHeader.getForFileContents(localFile); + expect(fileHeader).not.toBeUndefined(); + }); + } + }); + + it('should not get a file header for dummy files', async () => { + const headeredRoms = await new ROMScanner(new Options({ + input: ['./test/fixtures/roms/!(headered){,/}*'], + }), new ProgressBarFake()).scan(); + expect(headeredRoms.length).toBeGreaterThan(0); + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < headeredRoms.length; i += 1) { + await headeredRoms[i].extract(async (localFile) => { + const fileHeader = await FileHeader.getForFileContents(localFile); + expect(fileHeader).toBeUndefined(); + }); + } + }); +}); diff --git a/test/types/releaseCandidate.test.ts b/test/types/releaseCandidate.test.ts index 087e1f034..720c0ac03 100644 --- a/test/types/releaseCandidate.test.ts +++ b/test/types/releaseCandidate.test.ts @@ -26,7 +26,7 @@ describe('getRegion', () => { }); describe('getLanguages', () => { - test.each(ReleaseCandidate.getLanguages())('should return the release language; %s', (language) => { + test.each(ReleaseCandidate.getLanguages())('should return the release language: %s', (language) => { const release = new Release('release', 'UNK', language); const releaseCandidate = new ReleaseCandidate(new Game(), release, [], []); expect(releaseCandidate.getLanguages()).toEqual([language]); @@ -37,7 +37,7 @@ describe('getLanguages', () => { ['En,Fr,De', ['EN', 'FR', 'DE']], ['It+En,Fr,De,Es,It,Nl,Sv,Da', ['IT', 'EN', 'FR', 'DE', 'ES', 'NL', 'SV', 'DA']], ['En,Fr,It+Es,It', ['EN', 'FR', 'IT', 'ES']], - ])('should return the language from game name; %s', (languages, expectedLanguages) => { + ])('should return the language from game name: %s', (languages, expectedLanguages) => { const releaseCandidate = new ReleaseCandidate(new Game({ name: `game (${languages})` }), undefined, [], []); expect(releaseCandidate.getLanguages()).toEqual(expectedLanguages); });