refactor: move ocr domain components to lib/ocr/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
let { jobId, onDone }: Props = $props();
|
||||
|
||||
let status: 'running' | 'done' | 'error' = $state('running');
|
||||
let processed: number = $state(0);
|
||||
let total: number = $state(0);
|
||||
let currentPage: number = $state(0);
|
||||
let totalPages: number = $state(0);
|
||||
let retryCount: number = $state(0);
|
||||
|
||||
let progressPercent = $derived(total > 0 ? Math.round((processed / total) * 100) : 0);
|
||||
|
||||
$effect(() => {
|
||||
void retryCount; // track dependency to re-create EventSource on retry
|
||||
const source = new EventSource(`/api/ocr/jobs/${jobId}/progress`);
|
||||
|
||||
source.addEventListener('document', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
processed = data.processed;
|
||||
total = data.total;
|
||||
});
|
||||
|
||||
source.addEventListener('page', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
currentPage = data.page;
|
||||
totalPages = data.totalPages;
|
||||
});
|
||||
|
||||
source.addEventListener('done', () => {
|
||||
status = 'done';
|
||||
source.close();
|
||||
onDone();
|
||||
});
|
||||
|
||||
source.addEventListener('error', () => {
|
||||
status = 'error';
|
||||
source.close();
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
status = 'error';
|
||||
source.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if status === 'running'}
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-4">
|
||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_progress_heading()}
|
||||
</h3>
|
||||
<div class="bg-brand-sand h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="h-full bg-brand-mint transition-all duration-300"
|
||||
style="width: {progressPercent}%"
|
||||
role="progressbar"
|
||||
aria-label={m.ocr_progress_heading()}
|
||||
aria-valuenow={progressPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-2 text-right text-sm text-gray-500">
|
||||
<span aria-live="polite" aria-atomic="true">
|
||||
{m.ocr_progress_page({ current: String(currentPage), total: String(totalPages) })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div class="border-brand-sand rounded-sm border border-l-4 border-l-red-500 bg-white p-4">
|
||||
<h3 class="mb-2 text-sm font-semibold text-red-700">
|
||||
{m.ocr_error_heading()}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { retryCount++; status = 'running'; }}
|
||||
class="text-sm font-medium text-brand-navy transition-colors hover:text-brand-navy/80"
|
||||
>
|
||||
{m.ocr_error_retry()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { TrainingRun } from '$lib/types/training.js';
|
||||
|
||||
interface TrainingInfo {
|
||||
availableBlocks?: number;
|
||||
totalOcrBlocks?: number;
|
||||
availableDocuments?: number;
|
||||
ocrServiceAvailable?: boolean;
|
||||
lastRun?: TrainingRun | null;
|
||||
runs?: TrainingRun[];
|
||||
personNames?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trainingInfo: TrainingInfo | null;
|
||||
}
|
||||
|
||||
let { trainingInfo }: Props = $props();
|
||||
|
||||
let training = $state(false);
|
||||
let successMessage = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
const available = $derived(trainingInfo?.availableBlocks ?? 0);
|
||||
const tooFewBlocks = $derived(available < 5);
|
||||
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
|
||||
const disabled = $derived(training || tooFewBlocks || serviceDown);
|
||||
|
||||
async function startTraining() {
|
||||
training = true;
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/ocr/train', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
successMessage = m.training_success();
|
||||
setTimeout(() => {
|
||||
successMessage = null;
|
||||
}, 5000);
|
||||
} else {
|
||||
errorMessage = m.training_start_failed();
|
||||
}
|
||||
} catch {
|
||||
errorMessage = m.training_start_failed();
|
||||
} finally {
|
||||
training = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.training_ocr_heading()}</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.training_ocr_description()}</p>
|
||||
|
||||
<p class="mb-3 text-sm text-ink">
|
||||
{m.training_ocr_blocks_ready({ blocks: available, docs: trainingInfo?.availableDocuments ?? 0 })}
|
||||
<span class="text-ink-2"
|
||||
>{m.training_ocr_blocks_total({ total: trainingInfo?.totalOcrBlocks ?? 0 })}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<button
|
||||
onclick={startTraining}
|
||||
disabled={disabled}
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{training ? '…' : m.training_start_btn()}
|
||||
</button>
|
||||
|
||||
{#if tooFewBlocks}
|
||||
<p class="mt-2 text-xs text-ink-3">
|
||||
{m.training_too_few_blocks({ available })}
|
||||
</p>
|
||||
{:else if serviceDown}
|
||||
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<p class="mt-2 flex items-center gap-2 text-xs text-green-700" aria-live="polite">
|
||||
{successMessage}
|
||||
<button
|
||||
type="button"
|
||||
class="-my-2 ml-1 inline-flex h-11 w-11 items-center justify-center rounded-sm underline hover:no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
onclick={() => (successMessage = null)}
|
||||
aria-label={m.comp_dismiss()}>×</button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mt-2 text-xs text-red-600" aria-live="assertive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<h3 class="mt-6 mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.training_history_heading()}
|
||||
</h3>
|
||||
<TrainingHistory
|
||||
runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName !== 'blla')}
|
||||
personNames={trainingInfo?.personNames ?? {}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,114 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import OcrTrainingCard from './OcrTrainingCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const baseInfo = {
|
||||
availableBlocks: 10,
|
||||
totalOcrBlocks: 20,
|
||||
availableDocuments: 3,
|
||||
ocrServiceAvailable: true,
|
||||
lastRun: null,
|
||||
runs: []
|
||||
};
|
||||
|
||||
describe('OcrTrainingCard — disabled states', () => {
|
||||
it('disables button and shows hint when availableBlocks is 0', async () => {
|
||||
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 0 } });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
await expect
|
||||
.element(page.getByText(/Mindestens 5 geprüfte Blöcke erforderlich/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button and shows hint when availableBlocks is less than 5', async () => {
|
||||
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 3 } });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button and shows service-down warning when ocrServiceAvailable is false', async () => {
|
||||
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, ocrServiceAvailable: false } });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
await expect.element(page.getByText(/OCR-Dienst ist nicht erreichbar/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show service-down warning when blocks are insufficient', async () => {
|
||||
// tooFewBlocks hint takes priority over serviceDown hint
|
||||
render(OcrTrainingCard, {
|
||||
trainingInfo: { ...baseInfo, availableBlocks: 2, ocrServiceAvailable: false }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument();
|
||||
// serviceDown text should NOT appear because tooFewBlocks branch hides it
|
||||
const serviceMsg = document.querySelector('.text-orange-600');
|
||||
expect(serviceMsg).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OcrTrainingCard — enabled state', () => {
|
||||
it('enables button when availableBlocks >= 5 and service is up', async () => {
|
||||
render(OcrTrainingCard, { trainingInfo: baseInfo });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await expect.element(btn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows block count info text', async () => {
|
||||
render(OcrTrainingCard, {
|
||||
trainingInfo: { ...baseInfo, availableBlocks: 7, totalOcrBlocks: 15 }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/7/)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/von 15 OCR-Blöcken/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OcrTrainingCard — success dismiss button', () => {
|
||||
it('dismiss button has 44×44px touch target (h-11 w-11)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
|
||||
render(OcrTrainingCard, { trainingInfo: baseInfo });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await btn.click();
|
||||
|
||||
const dismissBtn = page.getByRole('button', { name: /Schließen/i });
|
||||
await expect.element(dismissBtn).toBeInTheDocument();
|
||||
|
||||
const el = await dismissBtn.element();
|
||||
expect(el.classList.contains('h-11')).toBe(true);
|
||||
expect(el.classList.contains('w-11')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OcrTrainingCard — in-flight state', () => {
|
||||
it('shows "…" while POST is in-flight', async () => {
|
||||
let resolveFetch!: (v: unknown) => void;
|
||||
const pendingFetch = new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockReturnValue(pendingFetch));
|
||||
|
||||
render(OcrTrainingCard, { trainingInfo: baseInfo });
|
||||
|
||||
const btn = page.getByRole('button', { name: /Training starten/i });
|
||||
await btn.click();
|
||||
|
||||
// While fetch is still pending the button label becomes "…"
|
||||
await expect.element(page.getByRole('button', { name: '…' })).toBeInTheDocument();
|
||||
|
||||
// Cleanup: resolve the pending promise
|
||||
resolveFetch({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import ScriptTypeSelect from '$lib/document/ScriptTypeSelect.svelte';
|
||||
|
||||
interface Props {
|
||||
blockCount: number;
|
||||
storedScriptType: string;
|
||||
onTrigger: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||
}
|
||||
|
||||
let { blockCount, storedScriptType, onTrigger }: Props = $props();
|
||||
|
||||
import { untrack } from 'svelte';
|
||||
let selectedScriptType: string = $state(
|
||||
untrack(() => (storedScriptType && storedScriptType !== 'UNKNOWN' ? storedScriptType : ''))
|
||||
);
|
||||
|
||||
function handleClick() {
|
||||
if (!selectedScriptType) return;
|
||||
onTrigger(selectedScriptType, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<ScriptTypeSelect bind:value={selectedScriptType} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedScriptType || blockCount === 0}
|
||||
title={!selectedScriptType ? m.ocr_trigger_btn_disabled() : undefined}
|
||||
onclick={handleClick}
|
||||
class="min-h-[44px] w-full rounded-sm bg-brand-navy font-sans text-sm font-medium text-white transition-colors hover:bg-brand-navy/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.ocr_trigger_btn()}
|
||||
</button>
|
||||
{#if blockCount === 0}
|
||||
<p class="text-xs text-ink-3">{m.ocr_trigger_no_annotations()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { TrainingRun } from '$lib/types/training.js';
|
||||
|
||||
interface TrainingInfo {
|
||||
availableSegBlocks?: number;
|
||||
ocrServiceAvailable?: boolean;
|
||||
runs?: TrainingRun[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trainingInfo: TrainingInfo | null;
|
||||
}
|
||||
|
||||
let { trainingInfo }: Props = $props();
|
||||
|
||||
let training = $state(false);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
const available = $derived(trainingInfo?.availableSegBlocks ?? 0);
|
||||
const tooFewBlocks = $derived(available < 5);
|
||||
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
|
||||
const disabled = $derived(training || tooFewBlocks || serviceDown);
|
||||
|
||||
async function startTraining() {
|
||||
training = true;
|
||||
successMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
successMessage = m.training_success();
|
||||
setTimeout(() => {
|
||||
successMessage = null;
|
||||
}, 5000);
|
||||
}
|
||||
} finally {
|
||||
training = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.training_seg_heading()}</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.training_seg_description()}</p>
|
||||
|
||||
<p class="mb-3 text-sm text-ink">
|
||||
{m.training_seg_blocks_ready({ blocks: available })}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onclick={startTraining}
|
||||
disabled={disabled}
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{training ? '…' : m.training_start_btn()}
|
||||
</button>
|
||||
|
||||
{#if tooFewBlocks}
|
||||
<p class="mt-2 text-xs text-ink-3">
|
||||
{m.training_seg_too_few_blocks({ available })}
|
||||
</p>
|
||||
{:else if serviceDown}
|
||||
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<p class="mt-2 text-xs text-green-700">{successMessage}</p>
|
||||
{/if}
|
||||
|
||||
<h3 class="mt-6 mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.training_history_heading()}
|
||||
</h3>
|
||||
<TrainingHistory
|
||||
runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName === 'blla')}
|
||||
showPersonColumns={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { TrainingRun } from '$lib/types/training.js';
|
||||
|
||||
interface Props {
|
||||
runs: TrainingRun[];
|
||||
personNames?: Record<string, string>;
|
||||
showPersonColumns?: boolean;
|
||||
}
|
||||
|
||||
let { runs, personNames, showPersonColumns = true }: Props = $props();
|
||||
|
||||
const COLLAPSED_COUNT = 3;
|
||||
let expanded = $state(false);
|
||||
|
||||
const visibleRuns = $derived(expanded ? runs : runs.slice(0, COLLAPSED_COUNT));
|
||||
const hasMore = $derived(runs.length > COLLAPSED_COUNT);
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return dateFormatter.format(new Date(iso));
|
||||
}
|
||||
|
||||
function formatCer(cer: number | undefined | null): string {
|
||||
if (cer == null) return '—';
|
||||
return (cer * 100).toFixed(1) + ' %';
|
||||
}
|
||||
</script>
|
||||
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<th class="pb-2 text-left">{m.training_history_col_date()}</th>
|
||||
<th class="pb-2 text-left">{m.training_history_col_status()}</th>
|
||||
{#if showPersonColumns}
|
||||
<th class="hidden pb-2 text-left md:table-cell">{m.training_col_type()}</th>
|
||||
<th class="hidden pb-2 text-left md:table-cell">{m.training_col_person()}</th>
|
||||
{/if}
|
||||
<th class="pb-2 text-right">{m.training_history_col_blocks()}</th>
|
||||
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_docs()}</th>
|
||||
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_cer()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="training-history-rows">
|
||||
{#if runs.length === 0}
|
||||
<tr>
|
||||
<td colspan={showPersonColumns ? 7 : 5} class="py-4 text-center text-sm text-ink-2">
|
||||
{m.training_history_empty()}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each visibleRuns as run (run.id)}
|
||||
<tr class="border-b border-line/50 last:border-0">
|
||||
<td class="py-2 text-ink-2">{formatDate(run.createdAt)}</td>
|
||||
<td class="py-2">
|
||||
{#if run.status === 'QUEUED'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
<span aria-hidden="true" class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
|
||||
{m.training_status_queued()}
|
||||
</span>
|
||||
{:else if run.status === 'DONE'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-3 w-3 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{m.training_status_done()}
|
||||
</span>
|
||||
{:else if run.status === 'FAILED'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-3 w-3 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{m.training_status_failed()}
|
||||
</span>
|
||||
{#if run.errorMessage}
|
||||
<details class="mt-0.5">
|
||||
<summary class="cursor-pointer text-xs text-red-700 underline">
|
||||
{m.training_error_detail_label()}
|
||||
</summary>
|
||||
<p class="mt-1 text-xs text-red-600">{run.errorMessage}</p>
|
||||
</details>
|
||||
{/if}
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-700"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 rounded-full bg-yellow-500 motion-safe:animate-pulse"
|
||||
></span>
|
||||
{m.training_status_running()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if showPersonColumns && run.personId && personNames?.[run.personId]}
|
||||
<span class="mt-0.5 block text-xs text-ink-3 md:hidden"
|
||||
>{personNames[run.personId]}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{#if showPersonColumns}
|
||||
<td class="hidden py-2 text-left text-ink-2 md:table-cell">
|
||||
{run.personId ? m.training_type_personalized() : m.training_type_base()}
|
||||
</td>
|
||||
<td class="hidden py-2 text-left text-ink-2 md:table-cell">
|
||||
{run.personId && personNames?.[run.personId] ? personNames[run.personId] : '—'}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="py-2 text-right text-ink-2">{run.blockCount}</td>
|
||||
<td class="hidden py-2 text-right text-ink-2 md:table-cell">{run.documentCount}</td>
|
||||
<td class="hidden py-2 text-right md:table-cell"
|
||||
>{run.status === 'DONE' && run.cer != null ? formatCer(run.cer) : '—'}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="mt-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="training-history-rows"
|
||||
class="rounded-sm py-2 text-xs font-medium text-ink-3 transition-colors hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 focus-visible:outline-none"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeRun(i: number) {
|
||||
return {
|
||||
id: `run-${i}`,
|
||||
status: 'DONE' as const,
|
||||
blockCount: 10,
|
||||
documentCount: 2,
|
||||
modelName: 'german_kurrent',
|
||||
createdAt: `2026-01-0${i + 1}T12:00:00Z`,
|
||||
completedAt: `2026-01-0${i + 1}T12:05:00Z`
|
||||
};
|
||||
}
|
||||
|
||||
const fiveRuns = Array.from({ length: 5 }, (_, i) => makeRun(i));
|
||||
const twoRuns = Array.from({ length: 2 }, (_, i) => makeRun(i));
|
||||
|
||||
describe('TrainingHistory — expand/collapse', () => {
|
||||
it('shows only 3 runs initially when more than 3 exist', async () => {
|
||||
render(TrainingHistory, { runs: fiveRuns });
|
||||
|
||||
const rows = page.getByRole('row');
|
||||
// 1 header row + 3 data rows = 4 total
|
||||
await expect.element(rows.nth(3)).toBeInTheDocument();
|
||||
await expect.element(rows.nth(4)).not.toBeInTheDocument();
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Mehr anzeigen/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all runs after clicking the expand button', async () => {
|
||||
render(TrainingHistory, { runs: fiveRuns });
|
||||
|
||||
await page.getByRole('button', { name: /Mehr anzeigen/i }).click();
|
||||
|
||||
const rows = page.getByRole('row');
|
||||
// 1 header row + 5 data rows = 6 total
|
||||
await expect.element(rows.nth(5)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the toggle button when 3 or fewer runs exist', async () => {
|
||||
render(TrainingHistory, { runs: twoRuns });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Mehr anzeigen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrainingHistory — status badges', () => {
|
||||
it('shows amber QUEUED badge for queued runs', async () => {
|
||||
const run = { ...makeRun(0), status: 'QUEUED' as const };
|
||||
render(TrainingHistory, { runs: [run] });
|
||||
|
||||
await expect.element(page.getByText(/Warteschlange/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrainingHistory — type and person columns', () => {
|
||||
it('shows "Basis" for runs without personId', async () => {
|
||||
render(TrainingHistory, { runs: [makeRun(0)] });
|
||||
|
||||
await expect.element(page.getByText(/Basis/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Personalisiert" for runs with personId', async () => {
|
||||
const run = { ...makeRun(0), personId: 'person-1' };
|
||||
render(TrainingHistory, { runs: [run], personNames: { 'person-1': 'Karl Müller' } });
|
||||
|
||||
await expect.element(page.getByText(/Personalisiert/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows person name from personNames for sender runs', async () => {
|
||||
const run = { ...makeRun(0), personId: 'person-1' };
|
||||
render(TrainingHistory, { runs: [run], personNames: { 'person-1': 'Karl Müller' } });
|
||||
|
||||
await expect.element(page.getByText(/Personalisiert/i)).toBeInTheDocument();
|
||||
// Name appears in both the mobile inline span and the desktop table cell
|
||||
await expect.element(page.getByText('Karl Müller').first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user