463 lines
36 KiB
TypeScript
463 lines
36 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
|
||
function previewWav(seconds = 2) {
|
||
const dataSize = 8000 * 2 * seconds;
|
||
const wav = Buffer.alloc(44 + dataSize);
|
||
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
|
||
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
||
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
|
||
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
|
||
return wav;
|
||
}
|
||
|
||
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine' | 'release' | 'mixed', audioAvailable = true) {
|
||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||
await page.goto('/login');
|
||
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
|
||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
|
||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||
await page.evaluate(async ({ scenario, audioAvailable }) => {
|
||
const modulePath = '/src/test/mobileFixtures.ts';
|
||
const { mobileFixtures } = await import(modulePath);
|
||
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
||
const file = { ...structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]), batch_has_cover: true };
|
||
const files = [
|
||
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
|
||
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
|
||
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
|
||
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
|
||
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' },
|
||
];
|
||
files[0]!.bitrate = 778000;
|
||
const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
|
||
const releaseId = '11111111-1111-4111-8111-111111111111';
|
||
const deluxeId = '22222222-2222-4222-8222-222222222222';
|
||
const tracks = [1, 2].map((n) => ({ recording_id: `recording-${n}`, title: `Song ${n}`, artist: 'Catalogue Artist', disc: 1, position: n, duration_ms: 180000 }));
|
||
const standard = { release: { id: releaseId, group_id: releaseId, title: 'Album', artist: 'Catalogue Artist', type: 'Album', date: '2024-01-01', country: 'RU', comment: 'Digital', has_cover: true, tracks },
|
||
matches: [{ file_id: 'visible', track_index: 0, confidence: 'high' }, { file_id: 'missing', track_index: 1, confidence: 'high' }], missing: [], existing: [], unmatched: [], matched: 2, total: 2, complete: true, confidence: 'high' };
|
||
const bonus = { ...tracks[0]!, title: 'Bonus', position: 3 };
|
||
const deluxe = { ...standard, release: { ...standard.release, id: deluxeId, comment: 'Deluxe', tracks: [...tracks, bonus] }, missing: [bonus], existing: [bonus], total: 3, complete: false };
|
||
let releaseReport = { state: 'ready', input_hash: 'original', selected_release: null as string | null, candidates: [standard, deluxe], audio_available: audioAvailable, audio_results: [] as { file_id: string; state: string }[] };
|
||
const groupA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', artist: 'Artist A', album: 'Album A', file_ids: ['visible'], has_cover: true, state: 'ready' };
|
||
const groupB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', artist: 'Artist B', album: 'Album B', file_ids: ['missing'], has_cover: false, state: 'ready' };
|
||
const groups = [groupA, groupB];
|
||
const groupReports = Object.fromEntries(groups.map((g, i) => [g.id, { ...releaseReport, group_id: g.id, groups, input_hash: g.id,
|
||
candidates: [{ ...standard, release: { ...standard.release, id: i ? deluxeId : releaseId, artist: g.artist, title: g.album, tracks: [tracks[i]] }, matches: [{ file_id: g.file_ids[0], track_index: 0, confidence: 'high' }], matched: 1, total: 1 }],
|
||
}]));
|
||
let rejectReleaseOnce = true;
|
||
let failOnce = true;
|
||
const deleted: string[] = [];
|
||
const original = window.fetch;
|
||
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
|
||
window.fetch = async (input, init) => {
|
||
const req = new Request(input, init);
|
||
const url = new URL(req.url);
|
||
if (scenario === 'mixed' && url.pathname.includes('/admin/uploads/batches/')) {
|
||
if (url.pathname.endsWith('/releases')) {
|
||
if (req.method === 'POST') {
|
||
const body = await req.json();
|
||
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
|
||
return json({ ...groupReports[body.group_id], state: 'queued' }, 202);
|
||
}
|
||
return json(groupReports[url.searchParams.get('group_id') ?? ''] ?? { ...releaseReport, state: 'needs_input', candidates: [], groups });
|
||
}
|
||
if (url.pathname.endsWith('/cover')) {
|
||
document.documentElement.dataset.releaseCoverGroup = url.searchParams.get('group_id') ?? '';
|
||
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release/' + releaseId + '/front-1200', exact_release: true });
|
||
}
|
||
if (url.pathname.endsWith('/release') && req.method === 'POST') {
|
||
const body = await req.json();
|
||
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
|
||
const target = groups.find((g) => g.id === body.group_id)!;
|
||
if (body.apply_cover) target.has_cover = true;
|
||
if (body.apply_metadata) {
|
||
const f = body.group_id === groupA.id ? files[0]! : missing;
|
||
Object.assign(f, { metadata: { artist: target.artist, albumartist: target.artist, album: target.album, title: 'Confirmed ' + target.album, genre: '' }, updated_at: new Date().toISOString() });
|
||
}
|
||
groupReports[body.group_id]!.selected_release = body.release_id;
|
||
return json(groupReports[body.group_id]);
|
||
}
|
||
}
|
||
if (scenario === 'mixed' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||
if (url.pathname.endsWith('/releases') && url.pathname.includes('/admin/uploads/batches/')) {
|
||
if (scenario !== 'release') return json({ state: 'disabled', input_hash: '', selected_release: null, candidates: [] });
|
||
if (req.method === 'POST') {
|
||
const body = await req.json();
|
||
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
|
||
if (body.audio) releaseReport = { ...releaseReport, audio_results: [{ file_id: 'visible', state: 'matched' }, { file_id: 'missing', state: 'ambiguous' }], candidates: [
|
||
{ ...standard, complete: false, confidence: 'medium', matches: [{ ...standard.matches[0]!, method: 'acoustid' }] } as typeof standard, deluxe,
|
||
] };
|
||
return json({ ...releaseReport, state: 'queued' }, 202);
|
||
}
|
||
return json(releaseReport);
|
||
}
|
||
if (scenario === 'release' && url.pathname.endsWith('/cover')) {
|
||
document.documentElement.dataset.releaseCoverRequests = String(Number(document.documentElement.dataset.releaseCoverRequests ?? 0) + 1);
|
||
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release-group/11111111-1111-4111-8111-111111111111/front-1200', exact_release: false });
|
||
}
|
||
if (scenario === 'release' && url.pathname.endsWith('/release') && req.method === 'POST') {
|
||
const body = await req.json();
|
||
if (body.apply_metadata && rejectReleaseOnce) { rejectReleaseOnce = false; return json({ code: 'uploads.release_stale', status: 409 }, 409); }
|
||
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
|
||
if (body.apply_metadata) {
|
||
for (const [index, f] of [files[0]!, missing].entries()) Object.assign(f, { metadata: { artist: 'Catalogue Artist', albumartist: 'Catalogue Artist', title: `Song ${index + 1}`, album: 'Album', year: 2024, track: index + 1, disc: 1, genre: '' }, updated_at: new Date().toISOString() });
|
||
}
|
||
releaseReport = { ...releaseReport, input_hash: 'applied', selected_release: body.release_id };
|
||
return json(releaseReport);
|
||
}
|
||
if (scenario === 'release' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
|
||
const body = await req.json();
|
||
const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0));
|
||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
|
||
document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`;
|
||
document.documentElement.dataset.coverBytes = String(bytes.length);
|
||
image.close();
|
||
return json({ items: [files[0], missing] });
|
||
}
|
||
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
|
||
const body = await req.json();
|
||
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
|
||
return json(missing);
|
||
}
|
||
if (scenario === 'template') {
|
||
if (url.pathname === '/api/v1/admin/uploads') {
|
||
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
|
||
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
|
||
}
|
||
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
|
||
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
|
||
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
|
||
const body = await req.json();
|
||
document.documentElement.dataset.savedTemplate = body.write_template;
|
||
Object.assign(source, body);
|
||
return json(source);
|
||
}
|
||
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
|
||
}
|
||
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
|
||
if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400);
|
||
if (url.searchParams.has('batch')) {
|
||
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
|
||
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
|
||
}
|
||
const statuses = url.searchParams.get('status')?.split(',') ?? [];
|
||
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
|
||
return json({ items: visible, next_cursor: 'more' });
|
||
}
|
||
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
|
||
const id = url.pathname.split('/').pop()!;
|
||
deleted.push(id);
|
||
document.documentElement.dataset.deleted = JSON.stringify(deleted);
|
||
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
|
||
files.find((f) => f.id === id)!.status = 'withdrawn';
|
||
return new Response(null, { status: 204 });
|
||
}
|
||
return original(input, init);
|
||
};
|
||
const target = scenario === 'queue' || scenario === 'release' || scenario === 'mixed' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
|
||
history.pushState({}, '', target);
|
||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||
}, { scenario, audioAvailable });
|
||
}
|
||
|
||
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
|
||
await setup(page, 'queue');
|
||
await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('2 × FLAC', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('778–924 kbps · 44.1 kHz · 16 bit', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
|
||
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
|
||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
||
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
||
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
|
||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||
// Continue keyboard selection after keyboard expansion, avoiding another scroll-dependent pointer action.
|
||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).press('Space');
|
||
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).not.toBeChecked();
|
||
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
|
||
const dialog = page.getByRole('dialog');
|
||
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
|
||
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
|
||
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
|
||
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
|
||
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
|
||
await setup(page, 'template');
|
||
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
|
||
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||
await expect(page.getByText(/180 из 200/)).toBeVisible();
|
||
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
|
||
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
|
||
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
||
await section.getByRole('checkbox').check();
|
||
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
|
||
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
|
||
});
|
||
|
||
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
|
||
await setup(page, 'mine');
|
||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
||
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
|
||
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
|
||
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
|
||
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
|
||
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
|
||
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
|
||
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
|
||
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
|
||
});
|
||
|
||
test('keeps withdrawn proposals in their own filter', async ({ page }) => {
|
||
await setup(page, 'mine');
|
||
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
||
await page.getByRole('button', { name: 'Отозванные', exact: true }).click();
|
||
await expect(page.getByText('withdrawn.flac', { exact: true }).first()).toBeVisible();
|
||
await expect(page.getByText('visible.flac', { exact: true })).toHaveCount(0);
|
||
await page.getByRole('button', { name: 'Не приняты', exact: true }).click();
|
||
await expect(page.getByText('rejected.flac', { exact: true }).first()).toBeVisible();
|
||
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
||
await page.getByRole('button', { name: 'Все', exact: true }).click();
|
||
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
|
||
await setup(page, 'template');
|
||
const sourcePath = new URL(page.url()).pathname;
|
||
const navigate = async (path: string) => page.evaluate((path) => {
|
||
history.pushState({}, '', path);
|
||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||
}, path);
|
||
await navigate('/admin/uploads');
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
|
||
await navigate(sourcePath);
|
||
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
||
await section.getByRole('checkbox').check();
|
||
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||
await navigate('/admin/uploads');
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
|
||
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test('queue preview only requests audio after play', async ({ page }) => {
|
||
const wav = previewWav();
|
||
let requested = 0;
|
||
let otherRequested = 0;
|
||
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => {
|
||
requested++;
|
||
return route.fulfill({ contentType: 'audio/wav', body: wav });
|
||
});
|
||
await page.route('**/api/v1/stream/mobile-preview', (route) => {
|
||
otherRequested++;
|
||
return route.fulfill({ contentType: 'audio/wav', body: wav });
|
||
});
|
||
await setup(page, 'queue');
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
const audio = page.locator('audio').first();
|
||
await expect(audio).toHaveAttribute('preload', 'none');
|
||
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
expect(requested).toBe(0);
|
||
expect(otherRequested).toBe(0);
|
||
await audio.evaluate((el: HTMLAudioElement) => el.play());
|
||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||
expect(requested).toBeGreaterThan(0);
|
||
expect(otherRequested).toBe(0);
|
||
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
|
||
});
|
||
|
||
test('queue preview explains HTTP failures and recovers on retry', async ({ page }) => {
|
||
let available = false;
|
||
// The E2E bridge mocks window.fetch; media diagnostics must reach the same
|
||
// intercepted HTTP route as the native audio element.
|
||
await page.addInitScript(() => Object.assign(window, { previewNativeFetch: window.fetch.bind(window) }));
|
||
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => available
|
||
? route.fulfill({ contentType: 'audio/wav', body: previewWav() })
|
||
: route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' }));
|
||
await setup(page, 'queue');
|
||
await page.evaluate(() => {
|
||
const original = window.fetch;
|
||
const native = (window as unknown as { previewNativeFetch: typeof fetch }).previewNativeFetch;
|
||
window.fetch = (input, init) => new URL(new Request(input, init).url).pathname.endsWith('/stream')
|
||
? native(input, init) : original(input, init);
|
||
});
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
const preview = page.locator('audio').first().locator('..');
|
||
await preview.locator('audio').evaluate((el: HTMLAudioElement) => el.play().catch(() => undefined));
|
||
await expect(preview.getByRole('alert')).toHaveText('Не удалось загрузить аудио: HTTP 403.');
|
||
available = true;
|
||
await preview.getByRole('button', { name: 'Повторить', exact: true }).click();
|
||
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
||
await expect(preview.getByRole('alert')).toHaveCount(0);
|
||
});
|
||
|
||
test('starting another queue preview pauses the previous track', async ({ page }) => {
|
||
const wav = previewWav(10);
|
||
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
||
await page.route('**/api/v1/stream/mobile-preview', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
||
await setup(page, 'queue');
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
const first = page.locator('audio').nth(0);
|
||
const second = page.locator('audio').nth(1);
|
||
await first.evaluate((el: HTMLAudioElement) => el.play());
|
||
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||
await second.evaluate((el: HTMLAudioElement) => el.play());
|
||
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||
expect(await second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
|
||
expect(await first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||
await first.evaluate((el: HTMLAudioElement) => el.play());
|
||
await expect.poll(() => second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||
expect(await first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
|
||
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||
});
|
||
|
||
test('oversized album cover is reduced before saving the batch', async ({ page }) => {
|
||
await setup(page, 'queue');
|
||
const png = await page.evaluate(() => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 3000; canvas.height = 1800;
|
||
canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height);
|
||
return canvas.toDataURL('image/png').split(',')[1]!;
|
||
});
|
||
// A valid image with trailing padding reproduces an oversized input without a large fixture in Git.
|
||
const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]);
|
||
await page.getByRole('button', { name: 'На всю пачку', exact: true }).click();
|
||
const dialog = page.getByRole('dialog');
|
||
await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover });
|
||
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
|
||
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
|
||
});
|
||
|
||
test('mixed batch checks each release and applies only within the chosen group', async ({ page }, info) => {
|
||
await setup(page, 'mixed');
|
||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||
await expect(page.getByText('Artist A — Album A', { exact: true })).toBeVisible();
|
||
await expect(page.getByText('Artist B — Album B', { exact: true })).toBeVisible();
|
||
await page.getByRole('checkbox', { name: 'Выбрать загруженные строки релиза Album A', exact: true }).check();
|
||
await expect(page.getByRole('checkbox', { name: 'Выбрать visible.flac', exact: true })).toBeChecked();
|
||
await expect(page.getByRole('checkbox', { name: 'Выбрать missing.flac', exact: true })).not.toBeChecked();
|
||
await page.getByRole('button', { name: 'Релизов в пачке: 2', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
|
||
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
||
await expect(dialog.getByText('Artist B — Album B', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText('Без обложки', { exact: true })).toBeVisible();
|
||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-release-cover-group', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
||
await page.screenshot({ path: info.outputPath('mixed-release.png') });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||
await dialog.getByRole('checkbox', { name: 'Добавить обложку выбранного релиза', exact: true }).check();
|
||
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
|
||
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
|
||
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText('Совпало 1 из 1', { exact: false })).toBeVisible();
|
||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
|
||
});
|
||
|
||
test('audio recognition starts only on request and keeps suggestions separate', async ({ page }) => {
|
||
await setup(page, 'release');
|
||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
|
||
await expect(dialog.getByText(/только аудиоотпечатки и длительности/)).toBeVisible();
|
||
await dialog.getByRole('button', { name: 'Распознать по звуку', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-release-search', '{"audio":true}');
|
||
await expect(dialog.getByText('По звуку распознано: 1 из 2 треков', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText(/Неоднозначное или недостаточно уверенное совпадение/)).toBeVisible();
|
||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||
await expect(dialog.getByText('Совпадение по звуку · AcoustID', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
|
||
});
|
||
|
||
test('audio identification without application key shows setup and stays disabled', async ({ page }) => {
|
||
await setup(page, 'release', false);
|
||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
|
||
await expect(dialog.getByText(/задайте ACOUSTID_API_KEY/)).toBeVisible();
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
|
||
});
|
||
|
||
test('release suggestions require explicit application and handle stale metadata', async ({ page }) => {
|
||
await setup(page, 'release');
|
||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||
await expect(dialog.getByText('Уверенность сопоставления: высокая', { exact: true })).toBeVisible();
|
||
const apply = dialog.getByRole('button', { name: 'Применить выбранное', exact: true });
|
||
await expect(apply).toBeDisabled();
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
|
||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||
await apply.click();
|
||
await expect(dialog.getByText(/Состав или теги пачки изменились/)).toBeVisible();
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||
await dialog.getByRole('textbox', { name: 'Исполнитель', exact: true }).fill('Catalogue Artist');
|
||
await dialog.getByRole('textbox', { name: 'Альбом', exact: true }).fill('Album');
|
||
await dialog.getByRole('button', { name: 'Найти / повторить поиск', exact: true }).click();
|
||
await expect(dialog.getByRole('status')).toHaveText('Найдено изданий: 2');
|
||
await expect(dialog.getByRole('combobox', { name: 'Издание' })).toBeVisible();
|
||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||
await expect(apply).toBeEnabled();
|
||
await apply.click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"11111111-1111-4111-8111-111111111111","apply_metadata":true,"apply_cover":false}');
|
||
await expect(dialog.getByText('Выбранное издание применено', { exact: true })).toBeVisible();
|
||
});
|
||
|
||
test('deluxe completeness and release-group artwork are shown before replacing an existing cover', async ({ page }, info) => {
|
||
await setup(page, 'release');
|
||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
|
||
await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
|
||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
|
||
await expect(dialog.getByRole('img', { name: 'Обложка пачки' })).toBeVisible();
|
||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||
await page.screenshot({ path: info.outputPath('release-comparison.png') });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||
await dialog.getByRole('checkbox', { name: 'Заменить текущую обложку пачки этой обложкой', exact: true }).check();
|
||
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"22222222-2222-4222-8222-222222222222","apply_metadata":false,"apply_cover":true}');
|
||
});
|