Compare commits
62 Commits
feat/issue
...
a59feec81a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59feec81a | ||
|
|
779ffaab55 | ||
|
|
b690c74ddf | ||
|
|
0797406f02 | ||
|
|
c94d2cec03 | ||
|
|
4da0bf71a0 | ||
|
|
da5d3c60b3 | ||
|
|
ed0d0bf331 | ||
|
|
899508f9ca | ||
|
|
d32e671e9d | ||
|
|
b61cfa081f | ||
|
|
d914385afc | ||
|
|
6cdfc1f6a3 | ||
|
|
ed6a2fb56f | ||
|
|
58545876cd | ||
|
|
687ebf495d | ||
|
|
bc10f2af06 | ||
|
|
0bfd342190 | ||
|
|
1973f88e56 | ||
|
|
9f044f429c | ||
|
|
7ad5e35fd6 | ||
|
|
e7afed5ac3 | ||
|
|
f48d1e3cd8 | ||
|
|
fc118f7032 | ||
|
|
4229e952fb | ||
|
|
e1259215ef | ||
|
|
f06d034b36 | ||
|
|
a6cd10f219 | ||
|
|
b8e6fe9ec9 | ||
|
|
763f1990cd | ||
|
|
ca62f50921 | ||
|
|
61f84a86ac | ||
|
|
0eb5c95c6c | ||
|
|
d662635392 | ||
|
|
b00be2548c | ||
|
|
01a8654347 | ||
|
|
c1b221412f | ||
|
|
76c14ea604 | ||
|
|
539842e849 | ||
|
|
ef7a51fe30 | ||
|
|
ec17cb123a | ||
|
|
801470093d | ||
|
|
af6ba6a9cc | ||
|
|
9acd5ec617 | ||
|
|
29a44b3cd1 | ||
|
|
5fe289b06b | ||
|
|
f76af8c678 | ||
|
|
69c739c6e3 | ||
|
|
43cf022f05 | ||
|
|
48d034dcb8 | ||
|
|
c335ddd686 | ||
|
|
7830a749a0 | ||
|
|
5b7c37391c | ||
|
|
ce72b07197 | ||
|
|
505804c893 | ||
|
|
67421a4c0c | ||
|
|
0ea0df4f72 | ||
|
|
077f5c85df | ||
|
|
018e272a3b | ||
|
|
0c4a0ead7b | ||
|
|
82b12d4383 | ||
|
|
01758e8e00 |
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BatchMetadataRequest(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditError(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record DocumentBatchSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DocumentBulkEditDTO {
|
||||
private List<UUID> documentIds;
|
||||
private List<String> tagNames;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private String documentLocation;
|
||||
private String archiveBox;
|
||||
private String archiveFolder;
|
||||
}
|
||||
@@ -111,6 +111,8 @@ public enum ErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||
BULK_EDIT_TOO_MANY_IDS,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -350,6 +350,52 @@ public class DocumentService {
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a bulk-edit DTO to a single document atomically.
|
||||
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||
* three location fields are replace-on-non-blank (null/blank means "no change").
|
||||
* Wrapped in its own transaction so a failure on one document never partially
|
||||
* mutates another in the batch loop.
|
||||
*/
|
||||
@Transactional
|
||||
public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
|
||||
Set<Tag> merged = new HashSet<>(doc.getTags());
|
||||
for (String name : dto.getTagNames()) {
|
||||
String clean = name.trim();
|
||||
if (!clean.isEmpty()) {
|
||||
merged.add(tagService.findOrCreate(clean));
|
||||
}
|
||||
}
|
||||
doc.setTags(merged);
|
||||
}
|
||||
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
Set<Person> merged = new HashSet<>(doc.getReceivers());
|
||||
merged.addAll(personService.getAllById(dto.getReceiverIds()));
|
||||
doc.setReceivers(merged);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(dto.getDocumentLocation())) {
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveBox())) {
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveFolder())) {
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsmethode: Erstellt Platzhalter (wird später vom Excel-Service genutzt)
|
||||
*/
|
||||
|
||||
@@ -1917,4 +1917,198 @@ class DocumentServiceTest {
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
|
||||
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
||||
|
||||
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
||||
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of("Kurrent"));
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto());
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(oldSender)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getById(senderId)).thenReturn(newSender);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(senderId);
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(newSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(existing)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto());
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(existing);
|
||||
verify(personService, never()).getById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID newReceiverId = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of(newReceiverId));
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactly(existing);
|
||||
verify(personService, never()).getAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("OldBox")
|
||||
.archiveFolder("OldFolder")
|
||||
.documentLocation("OldLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox("NewBox");
|
||||
dto.setArchiveFolder("NewFolder");
|
||||
dto.setDocumentLocation("NewLocation");
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("KeepBox")
|
||||
.archiveFolder("KeepFolder")
|
||||
.documentLocation("KeepLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox(" ");
|
||||
dto.setArchiveFolder("");
|
||||
// documentLocation left null
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
// Find and click the (?) help chip
|
||||
const helpBtn = page.locator('button[aria-expanded]');
|
||||
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||
await helpBtn.click();
|
||||
|
||||
// Popover should open
|
||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
||||
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||
|
||||
// Press Esc
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Focus should have returned to the chip
|
||||
await expect(helpBtn).toBeFocused();
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
const res = await request.post('/api/documents', {
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
||||
const doc = await res.json();
|
||||
return doc.id as string;
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
const docId = doc.id as string;
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
return docId;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
|
||||
await expect(nav).toBeHidden();
|
||||
}
|
||||
|
||||
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
|
||||
// text does not clutter the printed output (the print CSS declares display:none)
|
||||
for (const span of await page.locator('.new-tab').all()) {
|
||||
await expect(span).toBeHidden();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
async function createBlock(
|
||||
request: Parameters<typeof createEmptyDocument>[0],
|
||||
docId: string
|
||||
): Promise<void> {
|
||||
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||
}
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
});
|
||||
|
||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
});
|
||||
|
||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
// Toggle dark theme
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transcribe coach — with blocks', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
await createBlock(request, docId);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('[data-testid="mode-edit"]').click();
|
||||
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,7 +515,6 @@
|
||||
"scan_collapse": "Scan verkleinern",
|
||||
"transcription_empty_title": "Noch keine Transkription",
|
||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
|
||||
"transcription_panel_close": "Panel schließen",
|
||||
"person_alias_heading": "Namensverlauf",
|
||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||
@@ -828,9 +827,9 @@
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||
|
||||
@@ -515,7 +515,6 @@
|
||||
"scan_collapse": "Collapse scan",
|
||||
"transcription_empty_title": "No transcription yet",
|
||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
|
||||
"transcription_panel_close": "Close panel",
|
||||
"person_alias_heading": "Name history",
|
||||
"person_alias_empty": "No name changes recorded yet.",
|
||||
@@ -828,9 +827,9 @@
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Transcription rules",
|
||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||
|
||||
@@ -515,7 +515,6 @@
|
||||
"scan_collapse": "Reducir escaneo",
|
||||
"transcription_empty_title": "Sin transcripcion",
|
||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
|
||||
"transcription_panel_close": "Cerrar panel",
|
||||
"person_alias_heading": "Historial de nombres",
|
||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||
@@ -828,9 +827,9 @@
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Reglas de transcripción",
|
||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
<script module>
|
||||
// Module-level counter produces stable, predictable IDs across SSR + hydration.
|
||||
// Math.random() would generate different values server-side vs client-side,
|
||||
// causing a hydration mismatch on first render.
|
||||
let _counter = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -11,8 +18,9 @@ type Props = {
|
||||
|
||||
let { label, placement = 'bottom', children }: Props = $props();
|
||||
|
||||
const popoverId = `help-popover-${_counter++}`;
|
||||
|
||||
let open = $state(false);
|
||||
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||
|
||||
function toggle() {
|
||||
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<!--
|
||||
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
|
||||
audience is 60+). The inner <span> carries the visual 20×20px circle.
|
||||
-->
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
|
||||
aria-expanded={open}
|
||||
aria-controls={popoverId}
|
||||
onclick={toggle}
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
|
||||
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
?
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={popoverId}
|
||||
role="tooltip"
|
||||
role="region"
|
||||
aria-label={label}
|
||||
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||
>
|
||||
{#if children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes on Esc key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('closes on outside click', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
|
||||
it('opens on Enter key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
|
||||
it('opens on Space key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Space}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — hover-target', () => {
|
||||
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
const span = btn.querySelector('span')!;
|
||||
const btnClasses = btn.className.split(/\s+/);
|
||||
const spanClasses = span.className.split(/\s+/);
|
||||
expect(btnClasses).toContain('group');
|
||||
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||
});
|
||||
|
||||
it('outer button has focus-visible ring for keyboard users', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
expect(btn.className).toContain('focus-visible:ring-2');
|
||||
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
|
||||
const popover = document.getElementById(controls!);
|
||||
expect(popover).not.toBeNull();
|
||||
});
|
||||
|
||||
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
|
||||
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
|
||||
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
|
||||
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
expect(id1).toBeTruthy();
|
||||
expect(id2).toBeTruthy();
|
||||
expect(id1).not.toBe(id2);
|
||||
// IDs must be deterministic (counter-based), not random hex
|
||||
expect(id1).toMatch(/^help-popover-\d+$/);
|
||||
expect(id2).toMatch(/^help-popover-\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,21 @@ type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
beispielInput?: string;
|
||||
beispielInputStrike?: boolean;
|
||||
beispielOutput?: string;
|
||||
beispielLabel?: string;
|
||||
};
|
||||
|
||||
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
|
||||
let {
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
beispielInput,
|
||||
beispielInputStrike = false,
|
||||
beispielOutput,
|
||||
beispielLabel = 'Beispiel'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||
@@ -18,12 +28,18 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||
|
||||
{#if beispielOutput !== undefined}
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
|
||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{beispielLabel}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink">
|
||||
→ <code class="font-mono">{beispielOutput}</code>
|
||||
{#if beispielInput !== undefined}
|
||||
<code
|
||||
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
|
||||
>{beispielInput}</code
|
||||
> →
|
||||
{/if}
|
||||
<code class="font-mono">{beispielOutput}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
|
||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||
<!-- Step 1 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</li>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</li>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script lang="ts">
|
||||
const prefersReducedMotion = $derived(
|
||||
// $derived from .matches is a one-shot snapshot — it doesn't react when the
|
||||
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
|
||||
let prefersReducedMotion = $state(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if prefersReducedMotion}
|
||||
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
|
||||
role="img"
|
||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
|
||||
role="img"
|
||||
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<!-- Kurrent writing (static) -->
|
||||
<g
|
||||
|
||||
@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { goto } from '$app/navigation';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
@@ -290,29 +289,6 @@ describe('BulkDocumentEditLayout', () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
|
||||
const service = createConfirmService();
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
context: new Map([[CONFIRM_KEY, service]])
|
||||
});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
discardBtn.click();
|
||||
|
||||
// The confirm dialog should open (service.options not null)
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
|
||||
|
||||
// Cancel — files should remain
|
||||
service.settle(false);
|
||||
await vi.waitFor(
|
||||
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
@@ -13,18 +13,22 @@ const rules = [
|
||||
icon: '✗',
|
||||
title: m.richtlinien_rule_durchgestrichen_title(),
|
||||
body: m.richtlinien_rule_durchgestrichen_body(),
|
||||
beispielInput: 'der Text',
|
||||
beispielInputStrike: true,
|
||||
beispielOutput: '[durchgestrichen: der Text]'
|
||||
},
|
||||
{
|
||||
icon: 'ſ',
|
||||
title: m.richtlinien_rule_langes_s_title(),
|
||||
body: m.richtlinien_rule_langes_s_body(),
|
||||
beispielOutput: 's'
|
||||
beispielInput: 'ſtraße',
|
||||
beispielOutput: 'straße'
|
||||
},
|
||||
{
|
||||
icon: '?',
|
||||
title: m.richtlinien_rule_name_title(),
|
||||
body: m.richtlinien_rule_name_body(),
|
||||
beispielInput: 'Müller',
|
||||
beispielOutput: '[Müller?]'
|
||||
},
|
||||
{
|
||||
@@ -46,7 +50,7 @@ const klaerungChips = [
|
||||
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<main class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<!-- Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
||||
|
||||
@@ -61,10 +65,23 @@ const klaerungChips = [
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
aria-label="{m.richtlinien_wiki_link()} — {m.common_opens_new_tab()}"
|
||||
class="inline-flex items-center gap-1.5 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.richtlinien_wiki_link()}
|
||||
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm6.75-3a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V3.56l-4.22 4.22a.75.75 0 0 1-1.06-1.06l4.22-4.22H11a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +95,8 @@ const klaerungChips = [
|
||||
icon={rule.icon}
|
||||
title={rule.title}
|
||||
body={rule.body}
|
||||
beispielInput={rule.beispielInput}
|
||||
beispielInputStrike={rule.beispielInputStrike}
|
||||
beispielOutput={rule.beispielOutput}
|
||||
beispielLabel={m.richtlinien_beispiel_label()}
|
||||
/>
|
||||
@@ -102,10 +121,10 @@ const klaerungChips = [
|
||||
|
||||
<!-- Closing card -->
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2>
|
||||
<h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@@ -113,10 +132,6 @@ const klaerungChips = [
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-tab {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
||||
// before prerendered HTML is visible.
|
||||
export const prerender = true;
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
|
||||
--color-focus-ring: var(--c-focus-ring);
|
||||
|
||||
/* Parchment — warm background for code/example blocks inside cards */
|
||||
--color-parchment: var(--c-parchment);
|
||||
|
||||
/* Danger — destructive action color */
|
||||
--color-danger: var(--c-danger);
|
||||
--color-danger-fg: var(--c-danger-fg);
|
||||
@@ -122,6 +125,9 @@
|
||||
--c-danger: #c0392b;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
||||
--c-parchment: #faf8f1;
|
||||
|
||||
/* Tag color tokens — decorative dot colors on tag chips */
|
||||
--c-tag-sage: #5a8a6a;
|
||||
--c-tag-sienna: #a0522d;
|
||||
@@ -203,6 +209,9 @@
|
||||
--c-danger: #e55347;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||
--c-parchment: #041828;
|
||||
|
||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||
--c-tag-sage: #7abf8a;
|
||||
--c-tag-sienna: #cc7050;
|
||||
@@ -267,6 +276,9 @@
|
||||
--c-danger: #e55347;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||
--c-parchment: #041828;
|
||||
|
||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||
--c-tag-sage: #7abf8a;
|
||||
--c-tag-sienna: #cc7050;
|
||||
|
||||
Reference in New Issue
Block a user