Files
ytplayer/tests/recommendations.spec.js

115 lines
7.8 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
const picks = Array.from({ length: 8 }, (_, i) => ({
id: 'recommend' + String(i).padStart(2, '0'), title: `Piano session ${i + 1} — a little room to listen`,
channel: 'Studio sessions', thumbnail: '/fixture-cover.svg', duration: 240,
reason: i < 2 ? 'One of your most played' : 'More from Studio sessions',
}));
async function recommendations(page, result = picks) {
await page.route('**/api/recommendations?**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: result }) }));
await page.evaluate(() => Recommendations.update(true));
await expect(page.locator('#homeRecommendations .rec-item')).toHaveCount(result.length);
}
for (const width of [320, 390, 1440]) {
test(`home recommendation corner is readable and reachable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await openClassic(page, { settings: width === 320 ? { textSize: 'xl' } : {} });
await recommendations(page);
await expect(page.getByRole('heading', { name: 'Recommended for you' })).toBeVisible();
const result = await page.evaluate(() => {
const host = document.querySelector('#homeRecommendations');
const box = host.getBoundingClientRect();
const overflow = [...host.querySelectorAll('*')].some(e => e.getBoundingClientRect().right > box.right + 1);
function contrast(el) {
const rgb = s => s.match(/[\d.]+/g).slice(0, 3).map(Number).map(n => { n /= 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; });
const luminance = s => { const a = rgb(s); return .2126 * a[0] + .7152 * a[1] + .0722 * a[2]; };
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-1').trim();
const probe = document.createElement('span'); probe.style.color = bg; document.body.appendChild(probe);
const b = luminance(getComputedStyle(probe).color); probe.remove();
const f = luminance(getComputedStyle(el).color); return (Math.max(f, b) + .05) / (Math.min(f, b) + .05);
}
return { overflow, width: box.width, contrast: contrast(host.querySelector('.rec-reason')) };
});
expect(result.overflow).toBe(false); expect(result.width).toBeGreaterThan(200); expect(result.contrast).toBeGreaterThanOrEqual(4.5);
await page.locator('#homeRecommendations .rec-heading').evaluate(el => el.scrollIntoView({ block: 'start' }));
await page.screenshot({ path: `/tmp/ytplayer-recommendations-${width}.png`, fullPage: true });
const last = page.locator('#homeRecommendations .rec-actions button').last();
await last.scrollIntoViewIfNeeded(); await expect(last).toBeInViewport();
});
}
test('recommendations can play, queue, and open playlist actions', async ({ page }) => {
await openClassic(page); await recommendations(page);
await page.getByRole('button', { name: 'Queue ' + picks[0].title, exact: true }).click();
expect(await page.evaluate(() => data.queue.some(v => v.id === 'recommend00'))).toBe(true);
await page.getByRole('button', { name: 'More actions for ' + picks[0].title }).click();
await expect(page.locator('#modal')).toBeVisible();
await page.evaluate(() => closeModal());
await page.evaluate(() => {
window.__played = null;
playFromList = (list, index, source) => { window.__played = { id: list[index].id, source }; };
});
await page.getByRole('button', { name: 'Play ' + picks[1].title, exact: true }).click();
expect(await page.evaluate(() => window.__played)).toEqual({ id: picks[1].id, source: 'recommendations' });
});
test('Search menu restores recommendations during playback on desktop and mobile', async ({ page }) => {
await openClassic(page, { populated: true }); await recommendations(page);
await showPlayingFixture(page);
await page.locator('.nav-item[data-view="search"]').click();
await expect(page.locator('#homeRecommendations')).toBeVisible();
await expect(page.locator('#miniBar')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('#bottomNav [data-view="history"]').click();
await page.locator('#bottomNav [data-view="search"]').click();
await expect(page.locator('#homeRecommendations')).toBeVisible();
});
test('a saved browser search submits metadata even when no server search is needed', async ({ page }) => {
await openClassic(page);
const collected = []; let searched = 0;
await page.route('**/api/catalog/collect', route => { collected.push(route.request().postDataJSON()); return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
await page.route('**/api/search?**', route => { searched++; return route.fulfill({ contentType: 'application/json', body: '{"ok":true,"results":[]}' }); });
await page.evaluate(async picks => { SearchLibrary.put('piano fixture', picks, Date.now()); await SearchLibrary.get('piano fixture'); }, picks);
await expect.poll(() => collected.length).toBeGreaterThan(0); collected.length = 0;
await page.locator('#searchInput').fill('piano fixture');
await page.locator('#searchForm').evaluate(el => el.requestSubmit());
await expect.poll(() => collected.length).toBeGreaterThan(0);
expect(collected[0].cards[0].id).toBe(picks[0].id); expect(searched).toBe(0);
});
test('offline uses saved recommendations and queues metadata for reconnect', async ({ page }) => {
await openClassic(page); await recommendations(page);
await page.route('**/api/recommendations?**', route => route.abort('failed'));
await page.route('**/api/catalog/collect', route => route.abort('failed'));
await page.evaluate(async picks => { await Recommendations.update(true); SearchLibrary.put('offline discovery', picks, Date.now()); }, picks);
await expect(page.getByText('Saved picks · reconnect to refresh')).toBeVisible();
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('ytpCatalogPending')).includes('offline discovery'))).toBe(true);
let received = false;
await page.route('**/api/catalog/collect', route => { received = true; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => received).toBe(true);
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('ytpCatalogPending')).length)).toBe(0);
});
test('cold start and failed recommendations keep search usable', async ({ page }) => {
await openClassic(page);
await expect(page.getByText('Search or play a few videos to start discovering recommendations here.')).toBeVisible();
await page.route('**/api/recommendations?**', route => route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"offline"}' }));
await page.evaluate(() => Recommendations.update(true));
await expect(page.getByText('Recommendations are unavailable. You can still search or play your playlists.')).toBeVisible();
await expect(page.locator('#searchInput')).toBeEditable();
});
test('switching profiles reloads recommendations and scopes saved picks', async ({ page }) => {
await openClassic(page); await recommendations(page);
const profiles = [];
await page.route('**/api/recommendations?**', route => { profiles.push(new URL(route.request().url()).searchParams.get('profile')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [picks[2]] }) }); });
await page.evaluate(async () => { data.profile = { name: 'test-listener' }; await Recommendations.update(true); });
await expect.poll(() => profiles.includes('test-listener')).toBe(true);
await expect.poll(() => page.evaluate(() => !!localStorage.getItem('ytpRecommendations:profile:test-listener'))).toBe(true);
await expect(page.locator('#homeRecommendations .rec-item')).toHaveCount(1);
});