feat(analysis): admin coverage overview + per-page result inspector UI

Reworks /admin/analysis from a blind enqueue form into a coverage browser:

- Loads a paginated overview of mangas with a CoverageBadge
  (Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
  done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
  NSFW + content-warning chips, scene description, tags, and OCR lines
  (labeled by kind). Unanalyzed/failed pages get a "Queue this page"
  action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
  toggle; enqueue refreshes the open chapter's grid so queued pages show.

API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.

Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:39:54 +02:00
parent 824f5acf22
commit 6bb72b8775
8 changed files with 902 additions and 269 deletions

View File

@@ -28,7 +28,11 @@ import {
listActiveJobs,
listMissingCovers,
reenqueueAnalysis,
analyzePage
analyzePage,
getAnalysisMangaCoverage,
getAnalysisChapterCoverage,
getAnalysisChapterPages,
getAnalysisPageDetail
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -534,4 +538,46 @@ describe('admin crawler api client', () => {
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
});
it('getAnalysisMangaCoverage passes search + pagination', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
);
await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('/v1/admin/analysis/mangas?');
expect(url).toContain('search=ber');
expect(url).toContain('limit=25');
expect(url).toContain('offset=50');
});
it('getAnalysisChapterCoverage unwraps items', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
);
const items = await getAnalysisChapterCoverage('m1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/);
expect(items).toHaveLength(1);
});
it('getAnalysisChapterPages unwraps items', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] })
);
const items = await getAnalysisChapterPages('c1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/);
expect(items[0].status).toBe('done');
});
it('getAnalysisPageDetail hits the page endpoint', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] })
);
const d = await getAnalysisPageDetail('p1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
expect(d.status).toBe('none');
});
});