445b1d3100
- 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>
269 lines
12 KiB
TypeScript
269 lines
12 KiB
TypeScript
import { act, cleanup, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// next-intl — Durchreiche t(key) => key (Muster picture-frame-widget.test.tsx)
|
|
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';
|
|
|
|
/**
|
|
* xframe-widget.test — NEU (quick-260921-qd3).
|
|
*
|
|
* Kachel: Leerzustand (auch bei http), genau ein <iframe> mit exakter
|
|
* Sandbox (T-QD3-01), no-referrer (T-QD3-05), lazy, leerem allow
|
|
* (T-QD3-06); Link „In neuem Tab öffnen“ als Ecksymbol oder in der
|
|
* Kopfleiste; Titel wird escaped (T-QD3-09); Neuladen per Timer haengt den
|
|
* Rahmen neu ein und raeumt beim Aushaengen (T-QD3-08); Bearbeitungs-Overlay
|
|
* nach dem Rahmen im DOM (T-QD3-07).
|
|
*
|
|
* jsdom laedt keine Unterressourcen — ein <iframe src="https://…"> erzeugt
|
|
* hier keinen Netzabruf.
|
|
*/
|
|
const URL_A = 'https://intern.example/board';
|
|
|
|
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
|
|
return render(<XframeWidget instanceId="xf-1" config={config} isEditMode={isEditMode} />);
|
|
}
|
|
|
|
function frame(): HTMLIFrameElement {
|
|
return screen.getByTestId('xframe-frame') as 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)', () => {
|
|
it('Test 1: leere Konfiguration -> Leerhinweis, kein <iframe>, kein Link', () => {
|
|
renderWidget({});
|
|
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Test 2: http-Adresse -> ebenfalls Leerzustand (Resolver weist ab)', () => {
|
|
renderWidget({ url: 'http://a.de' });
|
|
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Test 3: https-Adresse -> genau ein <iframe> mit src, title = Adresse, exakter Sandbox, no-referrer, lazy, allow leer, Nonce 0', () => {
|
|
const { container } = renderWidget({ url: URL_A });
|
|
expect(container.querySelectorAll('iframe')).toHaveLength(1);
|
|
const f = frame();
|
|
expect(f.getAttribute('src')).toBe(URL_A);
|
|
expect(f.getAttribute('title')).toBe(URL_A);
|
|
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
|
expect(f.getAttribute('sandbox')).not.toMatch(/top-navigation/);
|
|
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
|
|
expect(f.getAttribute('loading')).toBe('lazy');
|
|
expect(f.getAttribute('allow')).toBe('');
|
|
expect(f.getAttribute('data-reload-nonce')).toBe('0');
|
|
});
|
|
|
|
it('Test 4: ohne Titel -> kein <h2>, Link als Ecksymbol (absolute) mit href, _blank, noopener noreferrer', () => {
|
|
const { container } = renderWidget({ url: URL_A });
|
|
expect(container.querySelector('h2')).toBeNull();
|
|
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
|
expect(link.getAttribute('href')).toBe(URL_A);
|
|
expect(link.getAttribute('target')).toBe('_blank');
|
|
expect(link.getAttribute('rel')).toContain('noopener');
|
|
expect(link.getAttribute('rel')).toContain('noreferrer');
|
|
expect(link.className).toContain('absolute');
|
|
});
|
|
|
|
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
|
|
const { container } = renderWidget({ url: URL_A, title: 'Board' });
|
|
expect(container.querySelector('h2')).toHaveTextContent('Board');
|
|
expect(frame().getAttribute('title')).toBe('Board');
|
|
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
|
expect(link.closest('.border-b')).not.toBeNull();
|
|
expect(link.className).not.toContain('absolute');
|
|
});
|
|
|
|
it('Test 6: Titel <b>x</b> erscheint woertlich, kein <b>-Element (React-Escaping, T-QD3-09)', () => {
|
|
const { container } = renderWidget({ url: URL_A, title: '<b>x</b>' });
|
|
expect(screen.getByText('<b>x</b>')).toBeInTheDocument();
|
|
expect(container.querySelector('b')).toBeNull();
|
|
});
|
|
|
|
it('Test 7: reloadSeconds 60 -> ein Timer; nach 60 s neues <iframe>-Objekt mit Nonce 1, nach 120 s Nonce 2; unmount raeumt', () => {
|
|
const { unmount } = renderWidget({ url: URL_A, reloadSeconds: 60 });
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
const before = frame();
|
|
act(() => {
|
|
vi.advanceTimersByTime(60_000);
|
|
});
|
|
const after = frame();
|
|
expect(after).not.toBe(before);
|
|
expect(after.getAttribute('data-reload-nonce')).toBe('1');
|
|
expect(after.getAttribute('src')).toBe(URL_A);
|
|
act(() => {
|
|
vi.advanceTimersByTime(60_000);
|
|
});
|
|
expect(frame().getAttribute('data-reload-nonce')).toBe('2');
|
|
unmount();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('Test 8: reloadSeconds 0 -> kein Timer, Nonce bleibt 0', () => {
|
|
renderWidget({ url: URL_A, reloadSeconds: 0 });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
act(() => {
|
|
vi.advanceTimersByTime(3_600_000);
|
|
});
|
|
expect(frame().getAttribute('data-reload-nonce')).toBe('0');
|
|
});
|
|
|
|
it('Test 9: Bearbeitungsmodus + reloadSeconds 60 -> kein Timer (kein Neuladen beim Bearbeiten)', () => {
|
|
renderWidget({ url: URL_A, reloadSeconds: 60 }, true);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('Test 10: Bearbeitungsmodus -> Overlay aria-hidden, absolute inset-0, im DOM NACH dem <iframe>', () => {
|
|
renderWidget({ url: URL_A }, true);
|
|
const overlay = screen.getByTestId('xframe-edit-overlay');
|
|
expect(overlay.getAttribute('aria-hidden')).toBe('true');
|
|
expect(overlay.className).toContain('absolute');
|
|
expect(overlay.className).toContain('inset-0');
|
|
// FOLLOWING = 4: das Overlay folgt dem Rahmen in Dokumentreihenfolge
|
|
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
it('Test 11: Ansichtsmodus -> kein Overlay', () => {
|
|
renderWidget({ url: URL_A }, false);
|
|
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Test 12: Ecksymbol sitzt im Bearbeitungsmodus tiefer (top-6) als im Ansichtsmodus (top-1)', () => {
|
|
renderWidget({ url: URL_A }, false);
|
|
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-1');
|
|
cleanup();
|
|
renderWidget({ url: URL_A }, true);
|
|
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();
|
|
});
|
|
});
|