Files
Aniworld/tests/frontend/unit/context_menu.test.js
Lukas eabce18e41 feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
2026-06-21 07:52:22 +02:00

168 lines
5.4 KiB
JavaScript

/**
* 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=<series-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();
});
});