import { describe, it, expect } from 'vitest' import { buildArgs, parseExtraArgs, parseTrimSections, selectExtraArgs, formatCommandLine, sanitizeDirSegment, collectionOutputTemplate, CROP_SQUARE_PPA, ARIA2C_ARGS, type AccessOptions } from '../src/main/buildArgs' import { DEFAULT_DOWNLOAD_OPTIONS, type DownloadOptions, type StartDownloadOptions } from '@shared/ipc' // --- Fixtures --------------------------------------------------------------- const BIN = 'C:/fake/bin' const OUT = 'C:/out/%(title)s.%(ext)s' const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false } function opts(overrides: Partial = {}): StartDownloadOptions { return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides } } function audio(overrides: Partial = {}): StartDownloadOptions { return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides }) } function dlo(overrides: Partial = {}): DownloadOptions { return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides } } function build( o: StartDownloadOptions, d: DownloadOptions, access: AccessOptions = NO_ACCESS, extraArgs: string[] = [] ): string[] { return buildArgs(o, OUT, d, BIN, access, extraArgs) } /** True when `seq` appears as a contiguous run inside `argv`. */ function hasSeq(argv: string[], ...seq: string[]): boolean { if (seq.length === 0) return true for (let i = 0; i + seq.length <= argv.length; i++) { if (seq.every((s, j) => argv[i + j] === s)) return true } return false } // --- Invariants shared by every recipe -------------------------------------- describe('buildArgs scaffolding', () => { it('pins ffmpeg location to the supplied binDir', () => { expect(hasSeq(build(opts(), dlo()), '--ffmpeg-location', BIN)).toBe(true) }) it('terminates options with `--` immediately before the URL', () => { const argv = build(opts(), dlo()) expect(argv[argv.length - 2]).toBe('--') expect(argv[argv.length - 1]).toBe(URL) }) }) // --- Network: proxy / rate limit / aria2c ----------------------------------- describe('network options', () => { it('emits nothing when proxy/rateLimit are empty and aria2c is unset', () => { const argv = build(opts(), dlo(), NO_ACCESS) expect(argv).not.toContain('--proxy') expect(argv).not.toContain('--limit-rate') expect(argv).not.toContain('--downloader') }) it('emits --proxy with the trimmed value', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, proxy: ' socks5://127.0.0.1:1080 ' }) expect(hasSeq(argv, '--proxy', 'socks5://127.0.0.1:1080')).toBe(true) }) it('emits --limit-rate with the trimmed value', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, rateLimit: ' 2M ' }) expect(hasSeq(argv, '--limit-rate', '2M')).toBe(true) }) it('emits --downloader --downloader-args when an aria2c path is given', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, aria2cPath: 'C:/fake/bin/aria2c.exe' }) expect( hasSeq(argv, '--downloader', 'C:/fake/bin/aria2c.exe', '--downloader-args', ARIA2C_ARGS) ).toBe(true) }) it('omits --downloader when aria2cPath is not set', () => { const argv = build(opts(), dlo(), NO_ACCESS) expect(argv).not.toContain('--downloader-args') }) }) // --- Cookies: browser store vs. sign-in window's exported file -------------- describe('cookies', () => { it('emits nothing when no cookie source is configured', () => { const argv = build(opts(), dlo(), NO_ACCESS) expect(argv).not.toContain('--cookies-from-browser') expect(argv).not.toContain('--cookies') }) it('emits --cookies-from-browser when cookiesFromBrowser is set', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'firefox' }) expect(hasSeq(argv, '--cookies-from-browser', 'firefox')).toBe(true) expect(argv).not.toContain('--cookies') }) it('emits --cookies when only cookiesFile is set', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFile: 'C:/userdata/cookies.txt' }) expect(hasSeq(argv, '--cookies', 'C:/userdata/cookies.txt')).toBe(true) expect(argv).not.toContain('--cookies-from-browser') }) it('prefers cookiesFromBrowser over cookiesFile when both are set', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'chrome', cookiesFile: 'C:/userdata/cookies.txt' }) expect(hasSeq(argv, '--cookies-from-browser', 'chrome')).toBe(true) expect(argv).not.toContain('--cookies') }) }) // --- Restrict filenames / download archive ----------------------------------- describe('restrictFilenames / downloadArchivePath', () => { it('emits --restrict-filenames when set', () => { expect(build(opts(), dlo(), { ...NO_ACCESS, restrictFilenames: true })).toContain( '--restrict-filenames' ) }) it('omits --restrict-filenames by default', () => { expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--restrict-filenames') }) it('emits --download-archive when a path is given', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, downloadArchivePath: 'C:/userdata/archive.txt' }) expect(hasSeq(argv, '--download-archive', 'C:/userdata/archive.txt')).toBe(true) }) it('omits --download-archive when no path is given', () => { expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--download-archive') }) }) // --- Audio extraction format ------------------------------------------------ describe('audio: -x --audio-format ', () => { it.each(['mp3', 'opus', 'flac'] as const)('emits -x --audio-format %s', (fmt) => { const argv = build(audio(), dlo({ audioFormat: fmt })) expect(hasSeq(argv, '-x', '--audio-format', fmt)).toBe(true) }) it('maps the quality label to an --audio-quality value', () => { expect(hasSeq(build(audio({ quality: 'Best (MP3)' }), dlo()), '--audio-quality', '0')).toBe( true ) expect(hasSeq(build(audio({ quality: '320 kbps' }), dlo()), '--audio-quality', '320K')).toBe( true ) expect(hasSeq(build(audio({ quality: '128 kbps' }), dlo()), '--audio-quality', '128K')).toBe( true ) }) }) // --- Subtitles (video only) ------------------------------------------------- describe('embedSubtitles', () => { it('emits write/embed/sub-langs/convert without auto-subs by default', () => { const argv = build(opts(), dlo({ embedSubtitles: true })) expect( hasSeq(argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--convert-subs', 'srt') ).toBe(true) expect(argv).not.toContain('--write-auto-subs') }) it('inserts --write-auto-subs before --convert-subs when autoSubtitles is on', () => { const argv = build(opts(), dlo({ embedSubtitles: true, autoSubtitles: true })) expect( hasSeq( argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--write-auto-subs', '--convert-subs', 'srt' ) ).toBe(true) }) it('honours a custom subtitleLanguages selector', () => { const argv = build(opts(), dlo({ embedSubtitles: true, subtitleLanguages: 'en.*,es' })) expect(hasSeq(argv, '--sub-langs', 'en.*,es')).toBe(true) }) it('never writes subtitles for an audio download', () => { const argv = build(audio(), dlo({ embedSubtitles: true, autoSubtitles: true })) expect(argv).not.toContain('--write-subs') expect(argv).not.toContain('--embed-subs') }) }) // --- SponsorBlock ----------------------------------------------------------- describe('sponsorBlock', () => { it('remove → --sponsorblock-remove --force-keyframes-at-cuts', () => { const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'remove' })) expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor', '--force-keyframes-at-cuts')).toBe(true) expect(argv).not.toContain('--sponsorblock-mark') }) it('mark → --sponsorblock-mark , no keyframe forcing', () => { const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'mark' })) expect(hasSeq(argv, '--sponsorblock-mark', 'sponsor')).toBe(true) expect(argv).not.toContain('--sponsorblock-remove') expect(argv).not.toContain('--force-keyframes-at-cuts') }) it('joins multiple categories with commas', () => { const argv = build( opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: ['sponsor', 'intro', 'outro'] }) ) expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor,intro,outro')).toBe(true) }) it('emits nothing when sponsorBlock is off or category list is empty', () => { expect(build(opts(), dlo({ sponsorBlock: false })).join(' ')).not.toContain('sponsorblock') expect( build(opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: [] })).join(' ') ).not.toContain('sponsorblock') }) }) // --- Preferred video codec (sort tiebreaker) -------------------------------- describe('preferredVideoCodec → -S res,fps,vcodec:', () => { it('maps av1 to the av01 codec token', () => { expect( hasSeq(build(opts(), dlo({ preferredVideoCodec: 'av1' })), '-S', 'res,fps,vcodec:av01') ).toBe(true) }) it('passes h264 / vp9 through verbatim', () => { expect( hasSeq(build(opts(), dlo({ preferredVideoCodec: 'h264' })), '-S', 'res,fps,vcodec:h264') ).toBe(true) expect( hasSeq(build(opts(), dlo({ preferredVideoCodec: 'vp9' })), '-S', 'res,fps,vcodec:vp9') ).toBe(true) }) it('omits -S entirely when codec preference is "any"', () => { expect(build(opts(), dlo({ preferredVideoCodec: 'any' }))).not.toContain('-S') }) }) // --- Cropped square cover art (the highest-risk ffmpeg quoting) -------------- describe('cropThumbnail → --ppa crop', () => { it('emits --embed-thumbnail then the square-crop --ppa string', () => { const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: true })) expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true) }) it('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => { // Structural test: verify the key invariants without locking in the exact string, // so minor whitespace or quoting style tweaks don't break CI. expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/) expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg') expect(CROP_SQUARE_PPA).toMatch(/-vf\s+crop=/) // Commas inside gt() MUST be inside quotes so ffmpeg doesn't split on them. expect(CROP_SQUARE_PPA).toMatch(/crop="[^"]*,/) }) it('does not add --ppa when cropThumbnail is off', () => { const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: false })) expect(argv).not.toContain('--ppa') expect(argv).toContain('--embed-thumbnail') }) it('does not add --ppa when the thumbnail itself is not embedded', () => { const argv = build(audio(), dlo({ embedThumbnail: false, cropThumbnail: true })) expect(argv).not.toContain('--ppa') expect(argv).not.toContain('--embed-thumbnail') }) it('suppresses --embed-thumbnail for webm (not supported by the container, L38)', () => { const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'webm' })) expect(argv).not.toContain('--embed-thumbnail') }) it('keeps --embed-thumbnail for mp4 container (L38)', () => { const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'mp4' })) expect(argv).toContain('--embed-thumbnail') }) }) // --- Custom commands (Phase C): extra args + display formatting ------------ describe('parseExtraArgs', () => { it('splits on whitespace', () => { expect(parseExtraArgs('--write-thumbnail --no-mtime')).toEqual([ '--write-thumbnail', '--no-mtime' ]) }) it('keeps a double-quoted span with spaces as one token', () => { expect(parseExtraArgs('--proxy "http://example.com:8080"')).toEqual([ '--proxy', 'http://example.com:8080' ]) }) it('keeps a single-quoted span with spaces as one token', () => { expect(parseExtraArgs("--output-na 'My Title - %(id)s.%(ext)s'")).toEqual([ '--output-na', 'My Title - %(id)s.%(ext)s' ]) }) it('returns an empty array for blank input', () => { expect(parseExtraArgs('')).toEqual([]) expect(parseExtraArgs(' ')).toEqual([]) }) it('collapses repeated whitespace between tokens', () => { expect(parseExtraArgs('--verbose --no-progress')).toEqual(['--verbose', '--no-progress']) }) }) describe('selectExtraArgs — custom-command consent gate (audit F2)', () => { const templates = [ { id: 'thumb', args: '--write-thumbnail' }, { id: 'danger', args: '--exec "calc.exe"' } ] it('returns [] when custom commands are disabled, even with a per-download override', () => { // The core fix: a renderer-supplied extraArgs must NOT run while the gate is off. expect( selectExtraArgs({ customCommandEnabled: false, perDownloadExtraArgs: '--exec "calc.exe"', defaultTemplateId: 'danger', templates }) ).toEqual([]) }) it('returns [] when disabled even if a default template id is set', () => { expect( selectExtraArgs({ customCommandEnabled: false, perDownloadExtraArgs: undefined, defaultTemplateId: 'thumb', templates }) ).toEqual([]) }) it('parses a per-download override when enabled', () => { expect( selectExtraArgs({ customCommandEnabled: true, perDownloadExtraArgs: '--write-thumbnail --no-mtime', defaultTemplateId: null, templates }) ).toEqual(['--write-thumbnail', '--no-mtime']) }) it('an explicit empty override yields [] even with a default template set', () => { expect( selectExtraArgs({ customCommandEnabled: true, perDownloadExtraArgs: '', defaultTemplateId: 'thumb', templates }) ).toEqual([]) }) it('falls back to the default template when no per-download override is given', () => { expect( selectExtraArgs({ customCommandEnabled: true, perDownloadExtraArgs: undefined, defaultTemplateId: 'thumb', templates }) ).toEqual(['--write-thumbnail']) }) it('returns [] when the default template id matches nothing', () => { expect( selectExtraArgs({ customCommandEnabled: true, perDownloadExtraArgs: undefined, defaultTemplateId: 'missing', templates }) ).toEqual([]) }) }) describe('formatCommandLine', () => { it('joins the exe and args with spaces when nothing needs quoting', () => { expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe( 'yt-dlp.exe -f best -- https://x.test/v' ) }) it('quotes an arg containing a space', () => { expect(formatCommandLine('yt-dlp.exe', ['--ppa', 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg'])).toBe( 'yt-dlp.exe --ppa "EmbedThumbnail+ffmpeg_o:-c:v mjpeg"' ) }) it('escapes embedded double quotes', () => { expect(formatCommandLine('yt-dlp.exe', ['say "hi"'])).toBe('yt-dlp.exe "say \\"hi\\""') }) it('renders an empty-string arg as ""', () => { expect(formatCommandLine('yt-dlp.exe', [''])).toBe('yt-dlp.exe ""') }) }) describe('buildArgs extraArgs', () => { it('appends extraArgs after every other option, still before -- url', () => { const argv = build(opts(), dlo(), NO_ACCESS, ['--write-thumbnail', '--no-mtime']) expect(hasSeq(argv, '--write-thumbnail', '--no-mtime', '--', URL)).toBe(true) }) it('omits nothing extra when extraArgs is empty', () => { const withEmpty = build(opts(), dlo(), NO_ACCESS, []) const withoutParam = build(opts(), dlo()) expect(withEmpty).toEqual(withoutParam) }) }) // --- Media-server sidecar files (Phase K) ----------------------------------- describe('sidecar files', () => { it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => { const argv = build( opts(), dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true }) ) expect(argv).toContain('--write-info-json') expect(argv).toContain('--write-thumbnail') expect(argv).toContain('--write-description') }) it('emits none of them by default', () => { const argv = build(opts(), dlo()) expect(argv).not.toContain('--write-info-json') expect(argv).not.toContain('--write-description') // (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail) expect(argv).not.toContain('--write-thumbnail') }) }) describe('YouTube extractor-args', () => { it('combines player_client and po_token into one youtube: group', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'web_safari', youtubePoToken: 'web.gvs+ABC' }) expect( hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC') ).toBe(true) }) it('emits only the fields that are set', () => { const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' }) expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true) }) it('emits nothing when both are empty', () => { expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args') }) }) describe('format sorting (-S)', () => { it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => { const argv = build( opts(), dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' }) ) expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true) expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false) }) it('falls back to the codec tiebreaker when formatSort is empty', () => { const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' })) expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true) }) }) describe('selectExtraArgs url-pattern matching', () => { const base = { customCommandEnabled: true, perDownloadExtraArgs: undefined, defaultTemplateId: null as string | null } const templates = [ { id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' }, { id: 'b', args: '--write-thumbnail' } ] it('auto-applies a template whose urlPattern matches the URL', () => { const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' }) expect(out).toEqual(['--audio-quality', '0']) }) it('does not apply a urlPattern template to a non-matching URL', () => { const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' }) expect(out).toEqual([]) }) it('urlPattern match takes precedence over the global default template', () => { const out = selectExtraArgs({ ...base, defaultTemplateId: 'b', templates, url: 'https://soundcloud.com/x' }) expect(out).toEqual(['--audio-quality', '0']) }) it('a bad regex never matches (and is skipped safely)', () => { const out = selectExtraArgs({ ...base, templates: [{ id: 'c', args: '--x', urlPattern: '(' }], url: 'https://soundcloud.com/x' }) expect(out).toEqual([]) }) }) describe('chapters', () => { it('emits --split-chapters when splitChapters is on', () => { expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters') }) it('does not emit --split-chapters by default', () => { expect(build(opts(), dlo())).not.toContain('--split-chapters') }) it('emits --split-chapters independently of --embed-chapters', () => { const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false })) expect(argv).toContain('--split-chapters') expect(argv).not.toContain('--embed-chapters') }) }) describe('parseTrimSections', () => { it('normalises plain time ranges to *START-END specs', () => { expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00']) expect(parseTrimSections('90-120')).toEqual(['*90-120']) expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20']) }) it('splits on commas and newlines and trims whitespace', () => { expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([ '*0:30-1:00', '*2:00-2:30', '*3:00-3:30' ]) }) it('keeps an existing leading * but does not double it', () => { expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00']) }) it('drops malformed tokens so garbage never reaches the argv', () => { expect(parseTrimSections('not-a-range')).toEqual([]) expect(parseTrimSections('1:30')).toEqual([]) // no end expect(parseTrimSections('')).toEqual([]) expect(parseTrimSections(undefined)).toEqual([]) }) it('rejects times with more than two colon groups (L146)', () => { // H:MM:SS is the deepest valid form; a fourth component is nonsense and used // to slip through to yt-dlp, failing there instead of being rejected here. expect(parseTrimSections('1:2:3:4-5:6:7:8')).toEqual([]) expect(parseTrimSections('0:00:00:01-0:00:00:02')).toEqual([]) // The deepest valid form still passes. expect(parseTrimSections('1:02:03-1:02:04')).toEqual(['*1:02:03-1:02:04']) }) }) describe('trim (--download-sections)', () => { it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => { const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo()) expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true) expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true) expect(argv).toContain('--force-keyframes-at-cuts') }) it('emits nothing when there is no trim', () => { const argv = build(opts(), dlo()) expect(argv).not.toContain('--download-sections') }) it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => { const argv = build( opts({ trim: '0:30-1:00' }), dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] }) ) expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1) }) }) // --- Collection folder paths (Phase G, media-manager) ----------------------- describe('sanitizeDirSegment', () => { it('keeps an ordinary name (incl. spaces and hyphens) intact', () => { expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips') expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers') }) it('replaces Windows-illegal characters with a space and collapses runs', () => { expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F') expect(sanitizeDirSegment('Best of 2024 ')).toBe('Best of 2024 Live') }) it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => { expect(sanitizeDirSegment('..')).toBe('Untitled') expect(sanitizeDirSegment('../../etc')).toBe('etc') expect(sanitizeDirSegment(' trailing. ')).toBe('trailing') expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist') }) it('prefixes reserved Windows device names', () => { expect(sanitizeDirSegment('CON')).toBe('_CON') expect(sanitizeDirSegment('com1')).toBe('_com1') }) it('prefixes a reserved device name even when it carries an extension (audit T3)', () => { expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt') expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4') expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar') // a non-reserved name that merely starts with similar letters is left alone expect(sanitizeDirSegment('console.log')).toBe('console.log') }) it('falls back to Untitled for empty / all-illegal input', () => { expect(sanitizeDirSegment('')).toBe('Untitled') expect(sanitizeDirSegment('???')).toBe('Untitled') }) it('caps length to keep the overall path bounded', () => { expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80) }) }) describe('collectionOutputTemplate', () => { const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 } it('files into /// - ', () => { const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s') // normalise separators so the assertion is OS-independent expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s') }) it('zero-pads the index to three digits and clamps bad values to 001', () => { expect( collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/') ).toContain('/042 - f') expect( collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/') ).toContain('/001 - f') expect( collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/') ).toContain('/001 - f') }) it('sanitizes the channel and playlist folder names', () => { const t = collectionOutputTemplate( 'C:/out', { channel: 'A/B', playlist: '..', index: 1 }, '%(title)s.%(ext)s' ) expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s') }) }) describe('metadata overrides (--replace-in-metadata)', () => { it('emits --embed-metadata and --replace-in-metadata for each set field', () => { const argv = build(opts(), dlo({ embedMetadata: false, metadataTitle: 'My Track' })) expect(argv).toContain('--embed-metadata') expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'My Track')).toBe(true) }) it('emits all three override fields when all are set', () => { const argv = build(opts(), dlo({ metadataTitle: 'T', metadataArtist: 'A', metadataAlbum: 'B' })) expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'T')).toBe(true) expect(hasSeq(argv, '--replace-in-metadata', 'artist', '^.*$', 'A')).toBe(true) expect(hasSeq(argv, '--replace-in-metadata', 'album', '^.*$', 'B')).toBe(true) }) it('passes a value containing a colon as a single arg without splitting', () => { const argv = build(opts(), dlo({ metadataTitle: 'Foo: Bar' })) expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'Foo: Bar')).toBe(true) }) it('escapes backslashes in the replacement string', () => { const argv = build(opts(), dlo({ metadataTitle: 'A\\B' })) expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'A\\\\B')).toBe(true) }) it('skips blank or whitespace-only values', () => { const argv = build(opts(), dlo({ metadataTitle: '', metadataArtist: ' ' })) expect(argv).not.toContain('--replace-in-metadata') }) it('forces --embed-metadata even when embedMetadata is false and an override is set', () => { const argv = build(opts(), dlo({ embedMetadata: false, metadataAlbum: 'Soundtrack' })) expect(argv).toContain('--embed-metadata') }) it('emits no override flags when no fields are set', () => { const argv = build(opts(), dlo({ embedMetadata: true })) expect(argv).not.toContain('--replace-in-metadata') }) })