import { describe, expect, it, vi } from 'vitest'; import { PreviewPanel } from '../../src/content/components/preview-panel'; import { VirtualPreviewList } from '../../src/content/components/virtual-preview-list'; import { I18nService } from '../../src/utils/i18n'; describe('VirtualPreviewList pending preview mapping', () => { it('renders original name and new name for a pending preview item', async () => { const element = new VirtualPreviewList(); element.items = [ { file: { id: 'file-1', name: 'old-episode.mkv', ext: 'mkv', size: 42, mtime: Date.now(), isDir: false, }, newName: 'new-episode.mkv', conflict: false, done: false, }, ]; document.body.appendChild(element); await element.updateComplete; const textContent = element.shadowRoot?.textContent ?? ''; expect(textContent).toContain('old-episode.mkv'); expect(textContent).toContain('new-episode.mkv'); element.remove(); }); it('renders conflict items with status badge and helper text', async () => { const element = new VirtualPreviewList(); element.items = [ { file: { id: 'file-conflict', name: 'old-conflict.mkv', ext: 'mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'new-conflict.mkv', conflict: true, done: false, }, ]; document.body.appendChild(element); await element.updateComplete; const textContent = element.shadowRoot?.textContent ?? ''; expect(textContent).toContain('old-conflict.mkv'); expect(textContent).toContain('new-conflict.mkv'); expect(textContent).toContain(I18nService.t('preview_badge_conflict')); expect(textContent).toContain(I18nService.t('error_api_conflict')); const badge = element.shadowRoot?.querySelector('.old-name-row .status-badge.conflict'); expect(badge).toBeTruthy(); element.remove(); }); it('shows localized extract_episode_not_found message instead of raw error key', async () => { const element = new VirtualPreviewList(); element.items = [ { file: { id: 'file-error', name: 'old-error.mkv', ext: 'mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'new-error.mkv', conflict: false, error: 'extract_episode_not_found', }, ]; document.body.appendChild(element); await element.updateComplete; const textContent = element.shadowRoot?.textContent ?? ''; expect(textContent).toContain(I18nService.t('error_extract_episode_not_found')); element.remove(); }); it('keeps pending badge next to old name row when showStatus is true', async () => { const element = new VirtualPreviewList(); element.showStatus = true; element.items = [ { file: { id: 'file-pending', name: 'old-pending.mkv', ext: 'mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'new-pending.mkv', conflict: false, done: false, }, ]; document.body.appendChild(element); await element.updateComplete; const badge = element.shadowRoot?.querySelector('.old-name-row .status-badge.pending'); expect(badge).toBeTruthy(); element.remove(); }); it('renders raw error text without warning when given a network error message', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const element = new VirtualPreviewList(); element.items = [ { file: { id: 'file-network', name: 'old-network.mkv', ext: 'mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'new-network.mkv', conflict: false, error: 'Network error', }, ]; document.body.appendChild(element); await element.updateComplete; const textContent = element.shadowRoot?.textContent ?? ''; expect(textContent).toContain('Network error'); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); element.remove(); }); it('highlights and scrolls to the suggested failure item in assist mode', async () => { const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const scrollSpy = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollSpy; const element = new VirtualPreviewList(); element.episodeExtractAssistEnabled = true; element.episodeExtractAssistState = { sampleFileId: 'file-error', sampleFileName: 'Show.S01E01', fillTarget: 'prefix', fillMode: 'segment', suggestedFailureFileId: 'file-error', }; element.items = [ { file: { id: 'file-error', name: 'Show.S01E01.mkv', ext: '.mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'Show.S01E01.mkv', conflict: false, error: 'extract_episode_not_found', }, ]; document.body.appendChild(element); await element.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); const focusedItem = element.shadowRoot?.querySelector('[data-file-id="file-error"]'); const hint = element.shadowRoot?.querySelector('[data-role="episode-assist-focus-hint"]'); expect(focusedItem?.className).toContain('assist-focus'); expect(hint?.textContent).toContain(I18nService.t('episode_assist_focus_hint')); expect(scrollSpy).toHaveBeenCalled(); HTMLElement.prototype.scrollIntoView = originalScrollIntoView; element.remove(); }); it('locks preview-item structure with semantic classes and target-specific truncation rules', async () => { const element = new VirtualPreviewList(); element.items = [ { file: { id: 'file-structure', name: 'old-very-long-name-that-should-truncate.mkv', ext: 'mkv', size: 1, mtime: Date.now(), isDir: false, }, newName: 'new-very-long-name-that-should-also-truncate.mkv', conflict: false, }, ]; document.body.appendChild(element); await element.updateComplete; const previewItem = element.shadowRoot?.querySelector('.preview-item'); expect(previewItem).toBeTruthy(); const previewContent = previewItem?.querySelector('.preview-content'); expect(previewContent).toBeTruthy(); const oldName = previewContent?.querySelector('.old-name.old-name-secondary'); expect(oldName).toBeTruthy(); const newNameText = previewContent?.querySelector('.new-name-text'); expect(newNameText).toBeTruthy(); const stylesArray = Array.isArray(VirtualPreviewList.styles) ? VirtualPreviewList.styles : [VirtualPreviewList.styles]; const staticCss = stylesArray .map((result) => ('cssText' in result ? (result as any).cssText : result.toString())) .join(' '); const selectorBlock = (selector: string) => { const escaped = selector.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'); const regex = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, 's'); const match = regex.exec(staticCss); expect(match, `missing ${selector}`).toBeTruthy(); return match![1]; }; const oldNameDeclarations = selectorBlock('.old-name.old-name-secondary'); expect(oldNameDeclarations).toContain('overflow: hidden'); expect(oldNameDeclarations).toContain('text-overflow: ellipsis'); expect(oldNameDeclarations).toContain('white-space: nowrap'); const baseOldNameDeclarations = selectorBlock('.old-name'); expect(baseOldNameDeclarations).toContain('font-size: 12px'); expect(baseOldNameDeclarations).toContain('color: var(--cdr-text-secondary, #595959)'); expect(baseOldNameDeclarations).not.toContain('overflow: hidden'); expect(baseOldNameDeclarations).not.toContain('text-overflow: ellipsis'); expect(baseOldNameDeclarations).not.toContain('white-space: nowrap'); const newNameDeclarations = selectorBlock('.new-name-primary .new-name-text'); expect(newNameDeclarations).toContain('overflow: hidden'); expect(newNameDeclarations).toContain('text-overflow: ellipsis'); expect(newNameDeclarations).toContain('white-space: nowrap'); const baseNewNameDeclarations = selectorBlock('.new-name-text'); expect(baseNewNameDeclarations).not.toContain('overflow: hidden'); expect(baseNewNameDeclarations).not.toContain('text-overflow: ellipsis'); expect(baseNewNameDeclarations).not.toContain('white-space: nowrap'); element.remove(); }); }); describe('PreviewPanel integration with VirtualPreviewList', () => { it('passes mixed preview items and status mode through while keeping stats accurate', async () => { const items = [ { file: { id: 'file-done', name: 'done-old.txt', ext: 'txt', size: 1, mtime: Date.now(), isDir: false, }, newName: 'done-new.txt', conflict: false, done: true, }, { file: { id: 'file-error', name: 'error-old.txt', ext: 'txt', size: 1, mtime: Date.now(), isDir: false, }, newName: 'error-new.txt', conflict: false, error: 'extract_episode_not_found', }, { file: { id: 'file-pending', name: 'pending-old.txt', ext: 'txt', size: 1, mtime: Date.now(), isDir: false, }, newName: 'pending-new.txt', conflict: false, done: false, }, ]; const panel = new PreviewPanel(); panel.items = items; panel.conflictCount = 1; panel.showStatus = true; document.body.appendChild(panel); await panel.updateComplete; const stats = new Map( Array.from(panel.shadowRoot?.querySelectorAll('.panel-stats .stat-item') ?? []).map((item) => [ item.querySelector('.stat-label')?.textContent?.trim() ?? '', item.querySelector('.stat-value')?.textContent?.trim() ?? '', ]) ); expect(stats.get(I18nService.t('preview_items'))).toBe('3'); expect(stats.get(I18nService.t('progress_remaining'))).toBe('1'); expect(stats.get(I18nService.t('progress_success'))).toBe('1'); expect(stats.get(I18nService.t('progress_failed'))).toBe('1'); expect(stats.get(I18nService.t('preview_summary_conflict'))).toBe('1'); const previewList = panel.shadowRoot?.querySelector('virtual-preview-list') as VirtualPreviewList | null; expect(previewList).toBeTruthy(); expect(previewList?.items).toBe(items); expect(previewList?.showStatus).toBe(true); await previewList?.updateComplete; const previewText = previewList?.shadowRoot?.textContent ?? ''; expect(previewText).toContain('done-old.txt'); expect(previewText).toContain('done-new.txt'); expect(previewText).toContain('error-old.txt'); expect(previewText).toContain('error-new.txt'); expect(previewText).toContain('pending-old.txt'); expect(previewText).toContain('pending-new.txt'); panel.remove(); }); });