feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen

- Resolver: crop (x/y/w/h, geklemmt auf 1280er-Seite, x verschoben statt abgewiesen), zoom (50..150, groesste Stufe <= n), readOnly (nur echtes true)
- xframe-crop.ts: reine Geometrie - computeCropLayout (contain + zentriert, scale 0 bis gemessen), applyCropDrag (Verschieben, vier Ecken, Gegenecke bleibt)
- Kachel: Clip + verschobener, skalierter <iframe> bei fester Layoutbreite 1280, ResizeObserver am Koerper; Zoom-Zweig mit Prozentmassen; 100 % wie bisher; readOnly-Flaeche nur im Ansichtsmodus
- Formular: Checkbox Ausschnitt (ein Aufruf mit crop + readOnly), Vorschau 1280 px breit mit derselben Sandbox und pointer-events none, Rahmen mit vier Griffen (Pointer-Events, Capture-Waechter fuer jsdom), Zahlenfelder, Zoom-Auswahl nur ohne Ausschnitt, Nur anzeigen, dauerhafter Hinweis
- Rahmen als <fieldset> statt div role=group (Biome useSemanticElements, kein biome-ignore)
- Test-Helfer stubResizeObserver, 13 neue Schluessel de/en, Ausschnitt auf der Umlaut-Allowlist; Web-Tests 604 -> 640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 12:11:24 +02:00
parent 5aa577a7fa
commit 445b1d3100
12 changed files with 1110 additions and 31 deletions
@@ -6,6 +6,7 @@ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { XFRAME_SANDBOX } from './xframe-config';
import { XframeWidget } from './xframe-widget';
@@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement {
beforeEach(() => {
vi.useFakeTimers();
// quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie)
stubResizeObserver({ width: 640, height: 360 });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('XframeWidget (quick-260921-qd3)', () => {
@@ -151,3 +155,114 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
});
});
/**
* quick-260922-ge2: Ausschnitt (Clip + verschobener, skalierter Rahmen bei
* gemessener Kachel 640x360), Zoom-Zweig (Prozentmasse + scale), 100 % wie
* heute, readOnly-Flaeche nur im Ansichtsmodus (T-GE2-02), Sandbox in jedem
* Zweig unveraendert (T-GE2-01).
*/
describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
it('Test 13: Ausschnitt 0/0/1280/720 in 640x360 -> data-tile-size, Clip 640x360 bei 0/0, Rahmen 1280x720 scale(0.5), Sandbox unveraendert', () => {
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } });
const body = container.querySelector('[data-tile-size]');
expect(body?.getAttribute('data-tile-size')).toBe('640x360');
const clip = screen.getByTestId('xframe-crop-clip');
expect(clip.style.position).toBe('absolute');
expect(clip.style.left).toBe('0px');
expect(clip.style.top).toBe('0px');
expect(clip.style.width).toBe('640px');
expect(clip.style.height).toBe('360px');
expect(clip.style.overflow).toBe('hidden');
const f = frame();
expect(clip.contains(f)).toBe(true);
expect(f.style.left).toBe('0px');
expect(f.style.top).toBe('0px');
expect(f.style.width).toBe('1280px');
expect(f.style.height).toBe('720px');
expect(f.style.transform).toBe('scale(0.5)');
expect(f.style.transformOrigin).toBe('0 0');
expect(f.className).not.toContain('h-full');
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(f.getAttribute('allow')).toBe('');
expect(f.getAttribute('loading')).toBe('lazy');
expect(f.getAttribute('data-reload-nonce')).toBe('0');
expect(f.getAttribute('src')).toBe(URL_A);
});
it('Test 14: Ausschnitt 200/100/800/400 in 640x360 -> Clip 640x320 bei 0/20, Rahmen um -160/-80 verschoben, scale(0.8)', () => {
renderWidget({ url: URL_A, crop: { x: 200, y: 100, w: 800, h: 400 } });
const clip = screen.getByTestId('xframe-crop-clip');
expect(clip.style.left).toBe('0px');
expect(clip.style.top).toBe('20px');
expect(clip.style.width).toBe('640px');
expect(clip.style.height).toBe('320px');
const f = frame();
expect(f.style.left).toBe('-160px');
expect(f.style.top).toBe('-80px');
expect(f.style.height).toBe('720px');
expect(f.style.transform).toBe('scale(0.8)');
});
it('Test 15: zoom 60 ohne Ausschnitt -> kein Clip, Rahmen 166.67 % mit scale(0.6), absolut positioniert', () => {
renderWidget({ url: URL_A, zoom: 60 });
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
const f = frame();
expect(f.style.width).toBe('166.67%');
expect(f.style.height).toBe('166.67%');
expect(f.style.transform).toBe('scale(0.6)');
expect(f.style.transformOrigin).toBe('0 0');
expect(f.className).toContain('absolute');
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
});
it('Test 16: zoom 100 und ohne zoom -> wie heute (kein style, h-full w-full, kein Clip); Ausschnitt + zoom 60 -> Zoom ignoriert', () => {
renderWidget({ url: URL_A, zoom: 100 });
expect(frame().style.transform).toBe('');
expect(frame().className).toContain('h-full');
expect(frame().className).toContain('w-full');
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
cleanup();
renderWidget({ url: URL_A });
expect(frame().style.transform).toBe('');
expect(frame().getAttribute('style')).toBeNull();
expect(frame().className).toContain('h-full');
cleanup();
renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, zoom: 60 });
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
expect(frame().style.transform).toBe('scale(0.5)');
expect(frame().style.transform).not.toBe('scale(0.6)');
});
it('Test 17: readOnly im Ansichtsmodus -> Flaeche aria-hidden, absolute inset-0, NACH dem Rahmen; kein Edit-Overlay; Link z-10 bleibt', () => {
renderWidget({ url: URL_A, readOnly: true }, false);
const overlay = screen.getByTestId('xframe-readonly-overlay');
expect(overlay.getAttribute('aria-hidden')).toBe('true');
expect(overlay.className).toContain('absolute');
expect(overlay.className).toContain('inset-0');
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.className).toContain('z-10');
});
it('Test 18: readOnly im Bearbeitungsmodus -> nur Edit-Overlay; ohne readOnly im Ansichtsmodus -> keine Flaeche', () => {
renderWidget({ url: URL_A, readOnly: true }, true);
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
cleanup();
renderWidget({ url: URL_A }, false);
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
});
it('Test 19: Ausschnitt im Bearbeitungsmodus -> Clip UND Edit-Overlay; mit Titel bleibt die Kopfleiste', () => {
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } }, true);
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
cleanup();
const second = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, title: 'Board' });
expect(second.container.querySelector('h2')).toHaveTextContent('Board');
expect(container.querySelector('h2')).toBeNull();
});
});