/** * Unit tests for AniWorld.ContextMenu * * Covers the right-click → "Anime Settings" navigation flow including * the regression where `hide()` was called BEFORE the navigation * `window.location.href` was built, which caused the key to be reset * to null and the URL to become `/anime/settings?key=null`. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const SRC_PATH = resolve( __dirname, '../../../src/server/web/static/js/index/context-menu.js', ); function loadContextMenu() { // Reset module state so each test gets a fresh closure. delete global.AniWorld; const src = readFileSync(SRC_PATH, 'utf8'); // Indirect eval → runs in global scope so `var AniWorld = window.AniWorld // || {}` mutates the real `global.AniWorld` (and through it, // `window.AniWorld` since happy-dom exposes global on window). // eslint-disable-next-line no-eval (0, eval)(src); return global.AniWorld.ContextMenu; } describe('ContextMenu — right-click → Anime Settings flow', () => { beforeEach(() => { document.body.innerHTML = ''; delete window.AniWorld; delete global.AniWorld; delete window.location; window.location = { href: '' }; }); afterEach(() => { vi.restoreAllMocks(); }); it('navigates to /anime/settings?key= after menu click', () => { const ctx = loadContextMenu(); expect(ctx).toBeTruthy(); expect(typeof ctx.show).toBe('function'); const grid = document.createElement('div'); grid.id = 'series-grid'; const card = document.createElement('div'); card.className = 'series-card'; card.setAttribute('data-key', 'attack-on-titan'); grid.appendChild(card); document.body.appendChild(grid); ctx.init(); card.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10, }), ); const menuItem = document.querySelector( '[data-action="anime-settings"]', ); expect(menuItem).toBeTruthy(); menuItem.click(); expect(window.location.href).toBe( '/anime/settings?key=attack-on-titan', ); }); it('encodes special characters in the key (URL-unsafe slugs)', () => { const ctx = loadContextMenu(); const grid = document.createElement('div'); grid.id = 'series-grid'; const card = document.createElement('div'); card.className = 'series-card'; card.setAttribute('data-key', 'a/b c'); grid.appendChild(card); document.body.appendChild(grid); ctx.init(); card.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5, }), ); document.querySelector('[data-action="anime-settings"]').click(); expect(window.location.href).toBe('/anime/settings?key=a%2Fb%20c'); }); it('source captures the key before calling hide() — regression guard', () => { // Static invariant: the click handler must read currentSeriesKey // BEFORE calling hide(). This guards against regressions of the // bug where hide() cleared currentSeriesKey before the URL was // built, resulting in /anime/settings?key=null. const src = readFileSync(SRC_PATH, 'utf8'); const clickHandlerMatch = src.match( /querySelector\('\[data-action="anime-settings"\]'\)\.addEventListener\('click',\s*function\s*\(\)\s*{([\s\S]*?)\}\);/, ); expect(clickHandlerMatch, 'click handler should exist').toBeTruthy(); const body = clickHandlerMatch[1]; expect(body).toMatch(/currentSeriesKey/); expect(body).toMatch(/\bhide\s*\(\s*\)/); expect(body).toMatch(/const\s+key\s*=\s*currentSeriesKey/); }); it('does not expose legacy nfo-diagnostics action', () => { const ctx = loadContextMenu(); const grid = document.createElement('div'); grid.id = 'series-grid'; const card = document.createElement('div'); card.className = 'series-card'; card.setAttribute('data-key', 'k'); grid.appendChild(card); document.body.appendChild(grid); ctx.init(); card.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5, }), ); expect( document.querySelector('[data-action="nfo-diagnostics"]'), ).toBeNull(); expect( document.querySelector('[data-action="anime-settings"]'), ).toBeTruthy(); }); it('right-click outside a series card does not show the menu', () => { const ctx = loadContextMenu(); const grid = document.createElement('div'); grid.id = 'series-grid'; document.body.appendChild(grid); ctx.init(); // Click on empty grid area — should NOT show menu (no .series-card ancestor). grid.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5, }), ); expect(document.querySelector('.context-menu')).toBeNull(); }); });