subdocument-splitting-plan

This commit is contained in:
rehlert committed 2026-10-06 22:52:35 +02:00
1 parent f09384d2e6
commit ca06ca2050
47 files changed
+4097 -136

No files matched your search

+228 -21
View File
@@ -1,6 +1,9 @@
import { expect, test } from '@playwright/test';
import { zipSync } from 'fflate';
import {
appendZipInput,
createMultiFileZip,
createPdf,
createZip,
dropFiles,
expectPdfDownload,
@@ -45,12 +48,12 @@ test('cancelling the replacement confirmation keeps the current archive', async
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, false);
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
await expect(page.getByText('2 ZIPs')).toHaveCount(0);
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
await expect(page.getByText('2 Dateien')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
@@ -69,7 +72,7 @@ test('confirming the replacement opens only the new ZIP and drops stale results'
// must ensure no late result from archiv-a reappears.
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
@@ -82,12 +85,12 @@ test('cancelling the system file picker leaves the workspace unchanged', async (
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await newZipButton(page).click();
await replaceZipInput(page).setInputFiles([]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0);
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
expect(pageErrors).toEqual([]);
});
@@ -100,13 +103,13 @@ test('the replacement input accepts the same ZIP twice', async ({ page }) => {
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
@@ -120,14 +123,14 @@ test('appending a ZIP keeps both archive editors visible', async ({ page }) => {
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
await waitForLoadedWorkspace(page, /2 Dateien · 2 Archive geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(2);
expect(pageErrors).toEqual([]);
});
@@ -141,7 +144,7 @@ test('bulk export only appears for two or more processed archives', async ({ pag
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(
@@ -151,7 +154,7 @@ test('bulk export only appears for two or more processed archives', async ({ pag
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
await waitForLoadedWorkspace(page, /2 Dateien · 2 Archive geöffnet/);
// The popover stays open, so the new bulk export action appears in place.
const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' });
@@ -173,13 +176,13 @@ test('clearing the workspace requires confirmation and returns to the dropzone',
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible();
await page.getByRole('button', { name: 'Abbrechen' }).click();
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click();
@@ -196,7 +199,7 @@ test('the preview-size slider works with arrow keys and survives a reload', asyn
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible();
await settingsButton(page).click();
@@ -212,7 +215,7 @@ test('the preview-size slider works with arrow keys and survives a reload', asyn
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(page.getByText('201 px')).toBeVisible();
await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201');
@@ -229,7 +232,7 @@ test('settings closes on Escape and outside click and returns focus to its trigg
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen');
await settingsButton(page).click();
@@ -254,12 +257,12 @@ test('mobile toolbar and settings popover stay inside the viewport', async ({ pa
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
const popover = page.locator('[data-slot="popover-content"]');
await expect(popover).toBeVisible();
await expect(page.getByText('Weitere ZIP hinzufügen')).toBeVisible();
await expect(page.getByText('Weitere Datei hinzufügen')).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
@@ -274,7 +277,7 @@ test('mobile toolbar and settings popover stay inside the viewport', async ({ pa
expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth);
// The toolbar sits in normal flow, so it must not cover the archive header.
const toolbarBox = await workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/).boundingBox();
const toolbarBox = await workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/).boundingBox();
const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox();
expect(toolbarBox).not.toBeNull();
expect(archiveHeaderBox).not.toBeNull();
@@ -282,3 +285,207 @@ test('mobile toolbar and settings popover stay inside the viewport', async ({ pa
expect(pageErrors).toEqual([]);
});
test('grouping mode creates Teildokumente and never duplicates their names', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Gruppieren' }).click();
const createButton = page.getByRole('button', { name: 'Teildokument erstellen' });
await expect(page.getByText('0 Dateien ausgewählt')).toBeVisible();
await expect(createButton).toBeDisabled();
const checkboxes = page.locator('li input[type="checkbox"]');
await checkboxes.nth(0).check();
await expect(page.getByText('1 Datei ausgewählt')).toBeVisible();
await createButton.click();
// the new block appears, is named "Teildokument 1", and its name input is focused
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
const nameInput = page.getByLabel('Teildokumentname');
await expect(nameInput).toHaveValue('Teildokument 1');
await expect(nameInput).toBeFocused();
// create a second block, dissolve the first, then create again:
// the next number must not collide with the surviving "Teildokument 2"
await checkboxes.nth(0).check();
await createButton.click();
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('Teildokument 2');
await page.getByRole('button', { name: 'Auflösen' }).first().click();
await expect(page.getByText('2 lose Dateien')).toBeVisible();
await checkboxes.nth(0).check();
await createButton.click();
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('Teildokument 3');
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: 'Fertig' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Gruppieren' })).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('grouping mode moves a selection into an existing Teildokument', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Neues Teildokument' }).click();
await expect(page.getByLabel('Teildokumentname')).toHaveValue('Teildokument 1');
await page.getByRole('button', { name: 'Gruppieren' }).click();
const checkboxes = page.locator('li input[type="checkbox"]');
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible();
const moveSelect = page.getByLabel('Auswahl in Teildokument verschieben');
await moveSelect.selectOption({ label: 'Teildokument 1' });
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
await expect(page.getByText('2 Dateien').first()).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('attachment menu moves files between sub-documents and the loose list', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
// create a sub-document from a loose file without drag-and-drop (touch path)
await page.getByRole('button', { name: 'Aktionen für antrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'In neues Teildokument verschieben' }).click();
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
// move a second loose file into the existing sub-document
await page.getByRole('button', { name: 'Aktionen für klage.pdf' }).click();
await page.getByRole('menuitem', { name: 'Nach „Teildokument 1“ verschieben' }).click();
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
// move it back out to the loose list
await page.getByRole('button', { name: 'Aktionen für klage.pdf' }).click();
await page.getByRole('menuitem', { name: 'Zu den losen Dateien verschieben' }).click();
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
// remove a loose file with confirmation
await page.getByRole('button', { name: 'Aktionen für gutachten.pdf' }).click();
await page.getByRole('menuitem', { name: 'Entfernen: gutachten.pdf' }).click();
await page.getByRole('button', { name: 'Entfernen', exact: true }).click();
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('quick action groups every loose file as its own Teildokument', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Jede Datei als Teildokument' }).click();
await expect(page.getByText('0 lose Dateien · 2 Teildokumente')).toBeVisible();
await expect(page.getByLabel('Teildokumentname').nth(0)).toHaveValue('antrag');
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('klage');
expect(pageErrors).toEqual([]);
});
test('splitting a PDF attachment cuts the selected pages into a new Teildokument', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zip = Buffer.from(zipSync({ 'vertrag.pdf': await createPdf(3, 'Vertrag') }));
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Aktionen für vertrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: vertrag.pdf' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await page.getByRole('button', { name: 'Seite 1' }).click();
await page.getByRole('button', { name: 'Seite 2' }).click();
await expect(page.getByText('2 von 3 Seiten ausgewählt')).toBeVisible();
await page.getByRole('button', { name: 'Als Teildokument übernehmen' }).click();
await expect(page.getByText('✓ Zugewiesen')).toHaveCount(2);
await page.locator('[data-split-apply]').click();
await expect(dialog).toHaveCount(0);
// the remaining page 3 stays loose, the cut pages form a new Teildokument
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
await expect(page.getByLabel('Teildokumentname')).toHaveValue('Seiten 1–2');
await expect(page.getByText('vertrag_Seiten_1-2.pdf')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('bulk split creates one Teildokument per page and removes the source', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = Buffer.from(zipSync({ 'vertrag.pdf': await createPdf(3, 'Vertrag') }));
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Aktionen für vertrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: vertrag.pdf' }).click();
await page.getByText('Jede Seite einzeln').click();
await expect(page.getByText('Es werden 3 Teildokumente erstellt.')).toBeVisible();
await page.locator('[data-split-apply]').click();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByText('0 lose Dateien · 3 Teildokumente')).toBeVisible();
await expect(page.getByLabel('Teildokumentname').nth(0)).toHaveValue('Seite 1');
await expect(page.getByLabel('Teildokumentname').nth(2)).toHaveValue('Seite 3');
expect(pageErrors).toEqual([]);
});
test('a dropped PDF opens as a workspace archive and can be split', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(2, 'Antrag');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'antrag.pdf', mimeType: 'application/pdf', buffer: pdf }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toBeVisible();
await page.getByRole('button', { name: 'Aktionen für antrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: antrag.pdf' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Seite 1' }).click();
await page.getByRole('button', { name: 'Als Teildokument übernehmen' }).click();
await page.locator('[data-split-apply]').click();
await expect(page.getByText('1 Teildokument')).toBeVisible();
expect(pageErrors).toEqual([]);
});
+13 -3
View File
@@ -159,13 +159,23 @@ export const trackPageErrors = (page: Page) => {
export const pdfDropzone = (page: Page) =>
page.getByRole('button', { name: 'PDF-Dateien hinzufügen' });
export const zipDropzone = (page: Page) =>
page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' });
page.getByRole('button', { name: 'beA-ZIP- oder PDF-Dateien hinzufügen' });
export const createZip = async (label: string, pageCount = 1) =>
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
/** ZIP with multiple named single-page PDF attachments. */
export const createMultiFileZip = async (labels: string[]) =>
Buffer.from(
zipSync(
Object.fromEntries(
await Promise.all(labels.map(async (label) => [`${label}.pdf`, await createPdf(1, label)]))
)
)
);
export const newZipButton = (page: Page) =>
page.getByRole('button', { name: 'Neue ZIP bearbeiten' });
page.getByRole('button', { name: 'Neue Datei bearbeiten' });
export const settingsButton = (page: Page) =>
page.getByRole('button', { name: 'Einstellungen', exact: true });
export const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
@@ -188,7 +198,7 @@ export const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boo
]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
if (confirm) {
await page.getByRole('button', { name: 'Neue ZIP öffnen' }).click();
await page.getByRole('button', { name: 'Neue Datei öffnen' }).click();
} else {
await page.getByRole('button', { name: 'Abbrechen' }).click();
}
+1 -1
View File
@@ -203,7 +203,7 @@ test('offline: documents, ZIP processing and PDF merge work without a network',
await dropFiles(page, zipDropzone(page), [
{ name: 'offline-archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/, { timeout: 45_000 });
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/, { timeout: 45_000 });
// a second tool route, still offline
await page.goto(`${BASE}/tools/merge`);
+14
View File
@@ -22,3 +22,17 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 });
expect(pageErrors).toEqual([]);
});
test('start page forwards a dropped PDF to beA', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(2, 'Antrag');
await page.goto('/');
await dropFiles(page, 'window', [
{ name: 'antrag.pdf', mimeType: 'application/pdf', buffer: pdf }
]);
await expect(page).toHaveURL(/\/tools\/bea$/);
await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 });
expect(pageErrors).toEqual([]);
});