Add resumable bulk lyrics operations
This commit is contained in:
43
tests/admin-bulk-lyrics.spec.js
Normal file
43
tests/admin-bulk-lyrics.spec.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('library selection queues LRCLIB, Whisper, LRC export and revision-backed clear jobs', async ({ page }) => {
|
||||
const calls = []; let latestJob = null;
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
let body = { ok: true };
|
||||
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
|
||||
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
|
||||
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 2, limit: 50, offset: 0, songs: [
|
||||
{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, cacheStatus: 'ready' },
|
||||
{ id: 'jNQXAC9IVRw', title: 'Saved song', channel: 'Band', lines: 0, cacheStatus: 'failed' },
|
||||
] };
|
||||
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'GET') body = { ok: true, jobs: latestJob ? [latestJob] : [] };
|
||||
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'POST') {
|
||||
const payload = request.postDataJSON(); calls.push(payload);
|
||||
latestJob = { id: `job-${calls.length}`, action: payload.action, status: 'complete', state: { done: payload.ids?.length || 2, total: payload.ids?.length || 2, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Done' }], fileReady: payload.action === 'export' } };
|
||||
body = { ok: true, job: latestJob };
|
||||
}
|
||||
if (url.pathname === '/api/admin/lyrics/jobs/job-1/pause' || url.pathname.endsWith('/resume') || url.pathname.endsWith('/cancel')) body = { ok: true, status: 'paused' };
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.goto('/admin.html#library');
|
||||
await expect(page.locator('#bulkLibraryTable')).toContainText('Amazing grace');
|
||||
|
||||
await page.locator('#bulkSelectAll').check(); await page.locator('#bulkAction').selectOption('lrclib'); await page.locator('#bulkStart').click();
|
||||
await expect.poll(() => calls.length).toBe(1);
|
||||
expect(calls[0]).toMatchObject({ action: 'lrclib', allFiltered: true, query: '' });
|
||||
|
||||
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
|
||||
await page.locator('#bulkAction').selectOption('whisper'); await page.locator('#bulkStart').click();
|
||||
await expect.poll(() => calls.length).toBe(2); expect(calls[1].action).toBe('whisper');
|
||||
|
||||
await page.locator('#bulkLibraryTable [data-bulk-song="jNQXAC9IVRw"]').check();
|
||||
await page.locator('#bulkAction').selectOption('export'); await page.locator('#bulkStart').click();
|
||||
await expect.poll(() => calls.length).toBe(3); expect(calls[2].action).toBe('export');
|
||||
await expect(page.locator('#bulkJobs a[href*="/export"]')).toBeVisible();
|
||||
|
||||
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
|
||||
await page.locator('#bulkAction').selectOption('clear'); await page.locator('#bulkStart').click();
|
||||
await expect.poll(() => calls.length).toBe(4); expect(calls[3]).toMatchObject({ action: 'clear', confirmClear: true });
|
||||
});
|
||||
71
tests/admin-tools-shots.spec.js
Normal file
71
tests/admin-tools-shots.spec.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function contrast(foreground, background) {
|
||||
const luminance = color => {
|
||||
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2);
|
||||
return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
|
||||
};
|
||||
const a = luminance(foreground), b = luminance(background);
|
||||
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
||||
}
|
||||
|
||||
test('capture the new admin tools screens for visual review', async ({ page }) => {
|
||||
const dir = path.resolve('plans/admin-tools-shots'); mkdirSync(dir, { recursive: true });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()); let body = { ok: true };
|
||||
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
|
||||
if (url.pathname === '/api/uploads') body.uploads = [];
|
||||
if (url.pathname === '/api/admin/media') body.media = [];
|
||||
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 1, flags: [{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', text: 'Grace will lead me home', reporter: 'listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'One word is wrong' }] };
|
||||
if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, limit: 50, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, source: 'auto', untimed: true, openReports: 1, reasons: ['automatic', 'untimed', 'reports'] }] };
|
||||
if (url.pathname === '/api/admin/lyrics/search') body = { ok: true, query: '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Grace will lead\u0002 me home', hasChapters: true, rev: 2 }] };
|
||||
if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 2, offset: 0, budget: 10_000_000, pinnedBudget: 2_000_000, bytes: 4_800_000, pinnedBytes: 1_200_000, utilization: .48, statuses: [{ status: 'ready', count: 1, bytes: 4_000_000 }, { status: 'queued', count: 1, bytes: 800_000 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'dQw4w9WgXcQ', status: 'ready', bytes: 4_000_000, lastAccess: 1791900000000, gen: 4, pinned: true }] };
|
||||
if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 1200, urlCount: 1190, detailCount: 980, thumbnailCount: 850, urlCoverage: .99, detailCoverage: .82, thumbnailCoverage: .71, sources: [{ source: 'deep', videos: 600, discoveries: 940, lastSeen: 1791900000 }, { source: 'search', videos: 500, discoveries: 1200, lastSeen: 1791800000 }], lastRun: { id: 'run-1', status: 'complete', createdAt: 1791800000, updatedAt: 1791900000, progress: { queriesDone: 160, newVideos: 340 } }, backfill: [{ source: 'history', cursor: 840, ceiling: 1000, done: false }] };
|
||||
if (url.pathname === '/api/admin/backups') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 20480, notes: 124, valid: true }] };
|
||||
if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, date: '2026-10-14', total: 124, diff: { added: 2, removed: 1, changed: 3, preview: [{ key: 'dQw4w9WgXcQ:lyrics', change: 'changed' }] } };
|
||||
if (url.pathname === '/api/admin/audit') body = { ok: true, total: 42, offset: 0, events: [{ id: 8, ts: 1791900000, actor: 'admin', action: 'lyrics.save', target: 'dQw4w9WgXcQ', detail: { rev: 4, lines: 22 } }] };
|
||||
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 1, limit: 50, offset: 0, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 22, rev: 4, cacheStatus: 'ready' }] };
|
||||
if (url.pathname === '/api/admin/lyrics/jobs') body = { ok: true, jobs: [{ id: 'bulk-1', action: 'lrclib', status: 'running', state: { done: 3, total: 12, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Saved revision 4' }], logs: [] } }] };
|
||||
if (url.pathname === '/api/admin/notes/recent') body = { ok: true, revs: [] };
|
||||
if (url.pathname === '/api/admin/analytics') body = { ok: true };
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto('/admin.html#editor');
|
||||
for (const tab of ['quality', 'lyric-search', 'cache', 'index', 'reports', 'backups', 'audit', 'library']) {
|
||||
await page.locator(`#tabsTop [data-tab="${tab}"]`).click();
|
||||
await expect(page.locator(`#p-${tab}`)).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
await page.screenshot({ path: path.join(dir, `${tab}.png`), fullPage: true });
|
||||
}
|
||||
for (const theme of ['dark', 'light', 'contrast']) {
|
||||
await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme);
|
||||
for (const width of [390, 1280]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
if (width === 390) {
|
||||
const nav = page.locator('#tabsBottom');
|
||||
const dimensions = await nav.evaluate(node => ({ client: node.clientWidth, scroll: node.scrollWidth, document: document.documentElement.scrollWidth }));
|
||||
expect(dimensions.scroll, 'phone tab strip should retain all admin destinations').toBeGreaterThan(dimensions.client);
|
||||
expect(dimensions.document, 'phone tab strip must not overflow the page').toBeLessThanOrEqual(width);
|
||||
const lastTab = nav.locator('[data-tab]').last();
|
||||
await lastTab.scrollIntoViewIfNeeded();
|
||||
await expect(lastTab).toBeInViewport();
|
||||
}
|
||||
const targetHeight = await page.locator('#bulkStart').evaluate(node => node.getBoundingClientRect().height);
|
||||
expect(targetHeight, `bulk action target in ${theme} at ${width}px`).toBeGreaterThanOrEqual(44);
|
||||
const colors = await page.locator('#bulkLyricsCard').evaluate(node => {
|
||||
const card = getComputedStyle(node), muted = getComputedStyle(node.querySelector('.muted'));
|
||||
const action = getComputedStyle(node.querySelector('#bulkStart'));
|
||||
return { text: muted.color, surface: card.backgroundColor, actionText: action.color, actionSurface: action.backgroundColor };
|
||||
});
|
||||
expect(contrast(colors.text, colors.surface), `muted text contrast ${theme} ${width}px`).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.actionText, colors.actionSurface), `primary action contrast ${theme} ${width}px ${JSON.stringify(colors)}`).toBeGreaterThanOrEqual(4.5);
|
||||
const overflow = await page.locator('#bulkLyricsCard').evaluate(node => node.scrollWidth > node.clientWidth);
|
||||
expect(overflow, `bulk card overflow in ${theme} at ${width}px`).toBe(false);
|
||||
await page.locator('#bulkLyricsCard').screenshot({ path: path.join(dir, `bulk-${theme}-${width}.png`) });
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user