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
168 lines
5.4 KiB
JavaScript
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();
|
|
});
|
|
});
|