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:
@@ -1,13 +1,15 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// E2E for the admin Analysis section: enqueue the whole library, or
|
||||
// search a manga and enqueue it / one of its chapters, with the
|
||||
// include-already-analyzed toggle. Fully mocked.
|
||||
// E2E for the admin Analysis section: coverage overview + badges, drill
|
||||
// manga → chapter → page, the page-detail modal, and the enqueue actions.
|
||||
// Fully mocked.
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
||||
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
||||
const pageDone = 'p1111111-1111-1111-1111-111111111111';
|
||||
const pageNone = 'p2222222-2222-2222-2222-222222222222';
|
||||
|
||||
const adminUser = {
|
||||
id: 'u11111111-1111-1111-1111-111111111111',
|
||||
@@ -23,149 +25,220 @@ const systemStats = {
|
||||
alerts: []
|
||||
};
|
||||
|
||||
const mangaRow = {
|
||||
id: mangaId,
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
sync_state: 'synced',
|
||||
chapter_count: 1,
|
||||
latest_seen_at: null
|
||||
};
|
||||
type Captured = { reenqueue: Record<string, unknown> | null; analyzeCalls: number };
|
||||
|
||||
const chapterRow = {
|
||||
id: chapterId,
|
||||
manga_id: mangaId,
|
||||
number: 1,
|
||||
title: 'The Brand',
|
||||
page_count: 20,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
sync_state: 'synced',
|
||||
latest_seen_at: null
|
||||
};
|
||||
|
||||
type Captured = { body: Record<string, unknown> | null };
|
||||
|
||||
async function mockAdmin(page: Page, captured: Captured) {
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
route.fulfill({
|
||||
async function mockAdmin(page: Page, cap: Captured) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: adminUser })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||
route.fulfill({
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/system', (route) =>
|
||||
route.fulfill({
|
||||
await page.route('**/api/v1/admin/system', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(systemStats)
|
||||
})
|
||||
);
|
||||
// Manga search. Registered before the chapters route so the more
|
||||
// specific chapters glob (added later) wins for chapter URLs.
|
||||
await page.route('**/api/v1/admin/mangas**', (route) =>
|
||||
route.fulfill({
|
||||
|
||||
// Coverage overview. Registered before the more specific routes below
|
||||
// so the later-registered (more specific) globs win for their URLs.
|
||||
await page.route('**/api/v1/admin/analysis/mangas**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [mangaRow],
|
||||
page: { limit: 20, offset: 0, total: 1 }
|
||||
items: [
|
||||
{
|
||||
manga_id: mangaId,
|
||||
title: 'Berserk',
|
||||
total_pages: 2,
|
||||
analyzed_pages: 1
|
||||
}
|
||||
],
|
||||
page: { limit: 25, offset: 0, total: 1 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/mangas/*/chapters**', (route) =>
|
||||
route.fulfill({
|
||||
await page.route('**/api/v1/admin/analysis/mangas/*/chapters', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [chapterRow],
|
||||
page: { limit: 500, offset: 0, total: 1 }
|
||||
items: [
|
||||
{
|
||||
chapter_id: chapterId,
|
||||
number: 1,
|
||||
title: 'The Brand',
|
||||
total_pages: 2,
|
||||
analyzed_pages: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
|
||||
captured.body = JSON.parse(route.request().postData() ?? '{}');
|
||||
return route.fulfill({
|
||||
await page.route('**/api/v1/admin/analysis/chapters/*/pages', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ page_id: pageDone, page_number: 1, status: 'done' },
|
||||
{ page_id: pageNone, page_number: 2, status: 'none' }
|
||||
]
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/admin/analysis/pages/${pageDone}`, (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
page_id: pageDone,
|
||||
page_number: 1,
|
||||
chapter_id: chapterId,
|
||||
manga_id: mangaId,
|
||||
status: 'done',
|
||||
is_nsfw: true,
|
||||
scene_description: 'A rainy street at night.',
|
||||
model: 'test-model',
|
||||
error: null,
|
||||
analyzed_at: '2026-06-13T12:00:00Z',
|
||||
ocr: [{ kind: 'speech', text: 'Hello there' }],
|
||||
tags: ['action', 'city'],
|
||||
content_warnings: ['gore']
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/admin/analysis/pages/${pageNone}`, (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
page_id: pageNone,
|
||||
page_number: 2,
|
||||
chapter_id: chapterId,
|
||||
manga_id: mangaId,
|
||||
status: 'none',
|
||||
is_nsfw: false,
|
||||
scene_description: null,
|
||||
model: null,
|
||||
error: null,
|
||||
analyzed_at: null,
|
||||
ocr: [],
|
||||
tags: [],
|
||||
content_warnings: []
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
|
||||
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
|
||||
return r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enqueued: 12 })
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/v1/admin/pages/${pageNone}/analyze`, (r) => {
|
||||
cap.analyzeCalls += 1;
|
||||
return r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enqueued: true })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('/admin/analysis', () => {
|
||||
test('queue the whole library posts only_unanalyzed=true', async ({ page }) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
test('overview shows coverage badges and queues the whole library', async ({
|
||||
page
|
||||
}) => {
|
||||
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||
await mockAdmin(page, cap);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId('admin-analysis-enqueue-library').click();
|
||||
const badge = page.getByTestId(`admin-analysis-coverage-manga-${mangaId}`);
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toContainText('Partial 1/2');
|
||||
|
||||
await page.getByTestId('admin-analysis-enqueue-library').click();
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||
'Enqueued 12 pages'
|
||||
);
|
||||
expect(captured.body).toEqual({ only_unanalyzed: true });
|
||||
expect(cap.reenqueue).toEqual({ only_unanalyzed: true });
|
||||
});
|
||||
|
||||
test('search a manga and queue it', async ({ page }) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
test('drill manga → chapter → page and inspect the result', async ({ page }) => {
|
||||
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||
await mockAdmin(page, cap);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId('admin-analysis-search').fill('ber');
|
||||
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`admin-analysis-manga-${mangaId}`)
|
||||
).toBeVisible();
|
||||
page.getByTestId(`admin-analysis-coverage-chapter-${chapterId}`)
|
||||
).toContainText('Partial 1/2');
|
||||
|
||||
await page.getByTestId(`admin-analysis-enqueue-manga-${mangaId}`).click();
|
||||
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
||||
const doneChip = page.getByTestId(`admin-analysis-page-${pageDone}`);
|
||||
await expect(doneChip).toHaveAttribute('data-status', 'done');
|
||||
|
||||
await doneChip.click();
|
||||
const modal = page.getByTestId('admin-analysis-detail');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('admin-analysis-detail-status')
|
||||
).toContainText('Analyzed');
|
||||
await expect(modal).toContainText('Hello there');
|
||||
await expect(modal).toContainText('action');
|
||||
await expect(page.getByTestId('admin-analysis-detail-warnings')).toContainText(
|
||||
'gore'
|
||||
);
|
||||
});
|
||||
|
||||
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
|
||||
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||
await mockAdmin(page, cap);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
||||
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
||||
await page.getByTestId(`admin-analysis-page-${pageNone}`).click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
|
||||
'Not analyzed'
|
||||
);
|
||||
await page.getByTestId('admin-analysis-detail-queue').click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||
'Berserk'
|
||||
'Queued page 2'
|
||||
);
|
||||
expect(captured.body).toEqual({ only_unanalyzed: true, manga_id: mangaId });
|
||||
});
|
||||
|
||||
test('expand chapters and queue one with include-analyzed on', async ({ page }) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId('admin-analysis-include-analyzed').check();
|
||||
await page.getByTestId('admin-analysis-search').fill('ber');
|
||||
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
||||
|
||||
await page.getByTestId(`admin-analysis-enqueue-chapter-${chapterId}`).click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
|
||||
expect(captured.body).toEqual({
|
||||
only_unanalyzed: false,
|
||||
chapter_id: chapterId
|
||||
});
|
||||
expect(cap.analyzeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user