Compare commits
4 Commits
b170085311
...
01b902e885
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b902e885 | ||
|
|
20db3d0d8f | ||
|
|
0306023610 | ||
|
|
8f836dfefb |
@@ -17,10 +17,15 @@ test.describe('Stammbaum — mobile read path (#692)', () => {
|
||||
// affordance appears; reduced-motion is already forced project-wide.
|
||||
test.use({ hasTouch: true, isMobile: true });
|
||||
|
||||
// Clear the affordance-dismissed flag before every test so the first-load
|
||||
// hint state is deterministic regardless of test order.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt'));
|
||||
});
|
||||
|
||||
for (const width of WIDTHS) {
|
||||
test(`affordance + controls render at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt'));
|
||||
await page.goto('/stammbaum');
|
||||
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ $effect(() => {
|
||||
type="button"
|
||||
onclick={hide}
|
||||
aria-label={m.stammbaum_affordance_dismiss()}
|
||||
class="rounded-sm p-0.5 text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
class="-my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
|
||||
@@ -103,7 +103,7 @@ const topDerived = $derived(
|
||||
type="button"
|
||||
onclick={onCentre}
|
||||
aria-label={m.stammbaum_centre_on_person()}
|
||||
class="rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -122,7 +122,7 @@ const topDerived = $derived(
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label={m.comp_dismiss()}
|
||||
class="rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
|
||||
@@ -15,3 +15,28 @@ describe('animateView (reduced motion)', () => {
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
|
||||
describe('animateView (animated path)', () => {
|
||||
const from = { x: 0, y: 0, z: 1 };
|
||||
const to = { x: 100, y: 0, z: 2 };
|
||||
|
||||
it('tweens across frames and lands exactly on the target', () => {
|
||||
const frames: { x: number }[] = [];
|
||||
const callbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal('performance', { now: () => 0 });
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => callbacks.push(cb));
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||
|
||||
animateView(from, to, (v) => frames.push(v), { durationMs: 100 });
|
||||
|
||||
callbacks[0](50); // t = 0.5 → an interpolated frame
|
||||
callbacks[callbacks.length - 1](100); // t = 1 → exact target
|
||||
|
||||
expect(frames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(frames[0].x).toBeGreaterThan(0);
|
||||
expect(frames[0].x).toBeLessThan(100);
|
||||
expect(frames.at(-1)).toEqual(to);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,13 +31,13 @@ describe('clampZoom', () => {
|
||||
});
|
||||
|
||||
it('clamps above MAX_ZOOM down to MAX_ZOOM', () => {
|
||||
expect(clampZoom(5)).toBe(MAX_ZOOM);
|
||||
expect(clampZoom(3.0001)).toBe(MAX_ZOOM);
|
||||
expect(clampZoom(99)).toBe(MAX_ZOOM);
|
||||
expect(clampZoom(MAX_ZOOM + 0.0001)).toBe(MAX_ZOOM);
|
||||
});
|
||||
|
||||
it('exposes the resolved zoom bounds', () => {
|
||||
expect(MIN_ZOOM).toBe(0.25);
|
||||
expect(MAX_ZOOM).toBe(3.0);
|
||||
expect(MAX_ZOOM).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
* project. See ADR-027 for why this is custom rather than a third-party library.
|
||||
*/
|
||||
|
||||
/** Resolved zoom bounds (OQ-001). */
|
||||
/**
|
||||
* Zoom bounds. OQ-001 originally resolved the ceiling to 3.0, but because zoom
|
||||
* is normalised to the whole tree, z=3 still shows too much of a wide tree to be
|
||||
* legible on a phone — so the ceiling was raised to 10 (product-owner revision,
|
||||
* #692). SVG stays vector-crisp at any zoom, so a generous max is harmless.
|
||||
*/
|
||||
export const MIN_ZOOM = 0.25;
|
||||
export const MAX_ZOOM = 3.0;
|
||||
export const MAX_ZOOM = 10;
|
||||
export const DEFAULT_ZOOM = 1;
|
||||
|
||||
/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
|
||||
|
||||
@@ -16,6 +16,12 @@ function mockNetwork() {
|
||||
} as unknown as ReturnType<typeof createApiClient>);
|
||||
}
|
||||
|
||||
function mockNetworkResponse(status: number) {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: false, status }, error: { code: 'X' } })
|
||||
} as unknown as ReturnType<typeof createApiClient>);
|
||||
}
|
||||
|
||||
function loadEvent(query = '') {
|
||||
const url = new URL(`http://localhost/stammbaum${query}`);
|
||||
return {
|
||||
@@ -53,4 +59,19 @@ describe('/stammbaum +page.server load — initialView', () => {
|
||||
const result = await load(loadEvent('?z=99') as never);
|
||||
expect(result.initialView.z).toBe(MAX_ZOOM);
|
||||
});
|
||||
|
||||
it('redirects to /login when the network API returns 401', async () => {
|
||||
mockNetworkResponse(401);
|
||||
const { load } = await import('./+page.server');
|
||||
await expect(load(loadEvent() as never)).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/login'
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an HTTP error when the network API fails', async () => {
|
||||
mockNetworkResponse(500);
|
||||
const { load } = await import('./+page.server');
|
||||
await expect(load(loadEvent() as never)).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,17 +88,22 @@ describe('stammbaum page', () => {
|
||||
await expect.element(page.getByRole('complementary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clamps the zoom level when the zoom-out button is clicked many times', async () => {
|
||||
it('clamps zoom-out at MIN_ZOOM (0.25), reflected in the mirrored ?z param', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
replaceState.mockClear();
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
const zoomOut = page.getByRole('button', { name: /verkleinern/i });
|
||||
for (let i = 0; i < 10; i++) await zoomOut.click();
|
||||
// Just verify that repeated clicks don't throw — branch coverage
|
||||
await expect.element(zoomOut).toBeVisible();
|
||||
// Default z=1; well over (1 - 0.25) / 0.1 = 8 steps to reach the floor.
|
||||
for (let i = 0; i < 15; i++) await zoomOut.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const url = replaceState.mock.calls.at(-1)![0] as URL;
|
||||
expect(url.searchParams.get('z')).toBe('0.25');
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user