feat(web): Favoriten-Widget — Symbol-Ersatzweg aus dem Browser, Sortierpfeile im Bearbeitungsmodus
- FavoriteIcon (favorites-widget.tsx): dreistufiger Ersatzweg proxy ->
direct -> none; Buchstaben-Platzhalter liegt immer darunter. Direktbild
nur bei http/https-URL (getDirectFaviconSrc), referrerPolicy
no-referrer, kein Drittanbieter-Favicon-Dienst. key={iconUrl|url}
setzt die Stufe bei Aenderung zurueck; kein style.display-Hack mehr
- handleMove + Sortierpfeile im Bearbeitungsmodus (nur bei nicht-inline-
Bearbeitung): optimistische Neuberechnung, PUT /favorites/order ueber
reorderFavorites; erster/letzter Eintrag deaktiviert; Fehler ->
Neuladen mit Fehlermeldung
- favorites-api.ts: reorderFavorites(widgetId, ids)
- de.json/en.json: widgets.favorites.moveUpButton/moveDownButton
- 5 neue Widget-Tests (Ersatzbild-Kette, Nicht-http-URL, Pfeilzustand,
Klick, Fehlerpfad); 11 bestehende unveraendert gruen; volle Web-Suite
64 Dateien/429 Tests und type-check gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl — passthrough t(key) => key (Pitfall: must be before imports)
|
||||
@@ -6,12 +6,16 @@ vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock favorites-api — all 4 functions as controllable vi.fn() spies
|
||||
// Mock favorites-api — all 5 functions as controllable vi.fn() spies
|
||||
// (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag
|
||||
// importiert das Widget undefined und der Klick auf einen Sortierpfeil
|
||||
// wirft TypeError.)
|
||||
vi.mock('@/lib/favorites-api', () => ({
|
||||
fetchFavorites: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
reorderFavorites: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
reorderFavorites,
|
||||
} from '@/lib/favorites-api';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
|
||||
@@ -35,6 +40,7 @@ const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
||||
|
||||
const BASE_FAVORITES = [
|
||||
@@ -414,4 +420,143 @@ describe('FavoritesWidget', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 260917-jdd: Browser-Ersatzweg (Proxy -> Direktbild -> Buchstabe) und
|
||||
// Sortierpfeile im Bearbeitungsmodus.
|
||||
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
||||
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const directImg = screen.getByTestId('icon-direct-fav-id-2') as HTMLImageElement;
|
||||
expect(directImg.src).toBe('https://notion.so/favicon.ico');
|
||||
expect(directImg.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(screen.queryByTestId('icon-proxy-fav-id-2')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N');
|
||||
});
|
||||
|
||||
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon');
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(proxyImg);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
||||
const directImg = screen.getByTestId('icon-direct-fav-id-1') as HTMLImageElement;
|
||||
expect(directImg.src).toBe('https://github.com/favicon.ico');
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(directImg);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
|
||||
});
|
||||
|
||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-3')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-proxy-fav-id-3')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-3')).toHaveTextContent('A');
|
||||
});
|
||||
|
||||
it('Pfeile: Zustand (erster/letzter deaktiviert) und Klick loest reorderFavorites aus', async () => {
|
||||
mockReorder.mockResolvedValue([
|
||||
{ ...BASE_FAVORITES[1], position: 0 },
|
||||
{ ...BASE_FAVORITES[0], position: 1 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const up = screen.getAllByRole('button', { name: 'favorites.moveUpButton' });
|
||||
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
||||
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[0]).not.toBeDisabled();
|
||||
expect(up[1]).not.toBeDisabled();
|
||||
expect(down[1]).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(down[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReorder).toHaveBeenCalledWith('fav-1', ['fav-id-2', 'fav-id-1']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const titles = within(screen.getByTestId('favorites-list'))
|
||||
.getAllByRole('link')
|
||||
.map((a) => a.querySelector('.truncate')?.textContent);
|
||||
expect(titles).toEqual(['Notion', 'GitHub']);
|
||||
});
|
||||
});
|
||||
|
||||
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
||||
mockReorder.mockRejectedValue(new Error('boom'));
|
||||
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(down[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('favorites.error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const titles = within(screen.getByTestId('favorites-list'))
|
||||
.getAllByRole('link')
|
||||
.map((a) => a.querySelector('.truncate')?.textContent);
|
||||
expect(titles).toEqual(['GitHub', 'Notion']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user