fix(web): Absicht/Ballast der letzten sechs useExhaustiveDependencies-Befunde entschieden
Befunde 16-21 (quick-260921-gof), Regel-Gesamtstand jetzt 0/446 (vorher 21/467), pnpm lint 5/5, pnpm type-check 4/4: - InvoiceHistoryTable.tsx (16) und sidebar.tsx (17): refreshKey / sidebarRefreshKey bleiben als begruendete Auffrisch-Ausloeser stehen (biome-ignore mit deutschem Grund) - ohne sie zeigt die DKV-Historie nach "Jetzt pruefen" bzw. die Seitenleiste nach einer Modul- Aktivierung den alten Stand. - ActivateModuleDialog.tsx (18): moduleId aus der Abhaengigkeitsliste entfernt - reiner Ballast, der Effekt holt ohnehin nur die vom Modul unabhaengige Gruppenliste und die Elternseite haengt den Dialog je Modul frisch ein. - GroupMembersModal.tsx (19/20): fetchMembers/fetchAllUsers in die Liste aufgenommen (echter Defekt) - ohne sie zeigt der Dialog bei einem Gruppenwechsel ohne Neuaufbau die Mitglieder der vorigen Gruppe. Neue Testdatei nach dem grants-matrix.test.tsx-Muster belegt genau diesen Fall. - grants/page.tsx (21): die Suchabgleich-Hilfsfunktion `matches` in den Merkungs-Rumpf verschoben statt im Bauteil-Rumpf zu bleiben - reiner Ballast, die bestehenden Suchfaelle bleiben unveraendert gruen. - sidebar.test.tsx um eine Zaehlprobe erweitert: Bump loest genau einen weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen. - Alle drei begruendeten biome-ignore-Zeilen sind jetzt gesetzt (erste Verwendung dieses Mechanismus im Projekt), alle wirkungslosen eslint-disable-Zeilen fuer diese Regel sind aus apps/web/src verschwunden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Group } from '../page';
|
||||
|
||||
/**
|
||||
* GroupMembersModal.test — quick-260921-gof, Befunde 19/20.
|
||||
*
|
||||
* Mirrors the grants-matrix.test.tsx pattern for this admin surface: a
|
||||
* global `fetch` stub keyed by URL, next-intl mocked with a flat
|
||||
* key -> German-copy lookup table. Text is hand-written here, NOT derived
|
||||
* from the component under test.
|
||||
*
|
||||
* Der entscheidende Fall ist der letzte: ein Wechsel der uebergebenen
|
||||
* Gruppe (ohne Neuaufbau der Komponente — die Elternseite haengt diesen
|
||||
* Dialog nicht per key neu ein) muss die Mitglieder der neuen Gruppe holen.
|
||||
* Vor Befund 19/20 waere das die Falle: der Mount-Effekt haette leer
|
||||
* ([]) als Abhaengigkeitsliste gefuehrt und beim Gruppenwechsel gar nicht
|
||||
* neu geladen — der Dialog haette die Mitglieder der vorigen Gruppe
|
||||
* gezeigt.
|
||||
*/
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'members.title': 'Mitglieder',
|
||||
'members.noMembers': 'Keine Mitglieder',
|
||||
'members.addTitle': 'Mitglied hinzufügen',
|
||||
'members.searchPlaceholder': 'Benutzer suchen...',
|
||||
'members.remove': 'Entfernen',
|
||||
'members.ldapManaged': 'Wird per AD verwaltet',
|
||||
'members.sourceLdap': 'AD',
|
||||
'members.sourceManual': 'Manuell',
|
||||
'members.addError': 'Mitglied konnte nicht hinzugefügt werden',
|
||||
loading: 'Laden...',
|
||||
close: 'Schließen',
|
||||
};
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => messages[key] ?? key,
|
||||
}));
|
||||
|
||||
import { GroupMembersModal } from './GroupMembersModal';
|
||||
|
||||
function group(overrides: Partial<Group> = {}): Group {
|
||||
return {
|
||||
id: 'g1',
|
||||
tenantId: 't1',
|
||||
name: 'Buchhaltung',
|
||||
ldapDn: null,
|
||||
internalName: null,
|
||||
isDefault: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
memberCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const MEMBERS_G1 = [
|
||||
{
|
||||
id: 'mem-1',
|
||||
groupId: 'g1',
|
||||
userId: 'u1',
|
||||
source: 'MANUAL',
|
||||
createdAt: '',
|
||||
user: { id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' },
|
||||
},
|
||||
];
|
||||
|
||||
const MEMBERS_G2 = [
|
||||
{
|
||||
id: 'mem-2',
|
||||
groupId: 'g2',
|
||||
userId: 'u2',
|
||||
source: 'MANUAL',
|
||||
createdAt: '',
|
||||
user: { id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' },
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_USERS = [
|
||||
{ id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' },
|
||||
{ id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' },
|
||||
];
|
||||
|
||||
function stubFetch() {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (typeof url === 'string' && url.endsWith('/groups/g1/members')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G1) });
|
||||
}
|
||||
if (typeof url === 'string' && url.endsWith('/groups/g2/members')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G2) });
|
||||
}
|
||||
if (typeof url === 'string' && url.endsWith('/users')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ALL_USERS) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('GroupMembersModal', () => {
|
||||
it('einhaengen ergibt genau einen Mitglieder- und einen Benutzer-Abruf', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
|
||||
render(<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const memberCalls = fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).endsWith('/groups/g1/members'),
|
||||
);
|
||||
const userCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/users'));
|
||||
expect(memberCalls).toHaveLength(1);
|
||||
expect(userCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('erneutes Zeichnen mit derselben Gruppe loest KEINE weiteren Abrufe aus', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
|
||||
const { rerender } = render(
|
||||
<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const callCountBefore = fetchMock.mock.calls.length;
|
||||
|
||||
rerender(<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.length).toBe(callCountBefore);
|
||||
});
|
||||
});
|
||||
|
||||
it('Befund 19/20: ein Wechsel der uebergebenen Gruppe (ohne Neuaufbau) loest genau einen weiteren Mitglieder-Abruf mit der neuen Kennung aus und zeigt die Mitglieder der neuen Gruppe', async () => {
|
||||
stubFetch();
|
||||
|
||||
const { rerender } = render(
|
||||
<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Gleiche Komponenteninstanz, neue Gruppe — genau der Fall, den die
|
||||
// Elternseite (admin/groups/page.tsx) erzeugt: kein `key`-Prop, also
|
||||
// kein Neuaufbau.
|
||||
rerender(
|
||||
<GroupMembersModal
|
||||
group={group({ id: 'g2', name: 'Vertrieb' })}
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByRole('list')).getByText('Bernd Mueller')).toBeInTheDocument();
|
||||
});
|
||||
expect(within(screen.getByRole('list')).queryByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -86,12 +86,15 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Befunde 19/20 (quick-260921-gof): beide Ladefunktionen sind bereits
|
||||
// stabil eingepackt (fetchMembers haengt nur an group.id, fetchAllUsers an
|
||||
// nichts) — sie in die Liste aufzunehmen ist gefahrlos. Ohne sie zeigt der
|
||||
// Dialog bei einem Gruppenwechsel ohne Neuaufbau (die Elternseite haengt
|
||||
// diesen Dialog nicht per key neu ein) die Mitglieder der vorigen Gruppe.
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
fetchAllUsers();
|
||||
// group.id is stable for the lifetime of this modal instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [fetchMembers, fetchAllUsers]);
|
||||
|
||||
const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]);
|
||||
|
||||
|
||||
@@ -64,7 +64,12 @@ export function ActivateModuleDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, moduleId]);
|
||||
// Befund 18 (quick-260921-gof): moduleId entfernt — der Effekt holt
|
||||
// ohnehin nur die vom Modul unabhaengige Gruppenliste, und die
|
||||
// Elternseite haengt diesen Dialog innerhalb einer Bedingung je
|
||||
// gewaehltem Modul frisch ein (open dort immer fest true) — moduleId
|
||||
// kann sich waehrend der Lebenszeit des Dialogs also nicht aendern.
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -131,13 +131,18 @@ export default function AdminModuleGrantsPage() {
|
||||
};
|
||||
|
||||
const searchLower = search.trim().toLowerCase();
|
||||
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
|
||||
|
||||
// Both axes are searched independently, then combined with the following
|
||||
// semantics (WINDOWS #14): a term that hits only one axis must not empty
|
||||
// the other axis, or no clickable cell ever remains. See <behavior> in
|
||||
// 260909-ab3-PLAN.md for the full truth table.
|
||||
//
|
||||
// Befund 21 (quick-260921-gof): `matches` lebt jetzt im Rumpf der Merkung
|
||||
// selbst statt im Bauteil-Rumpf — sie las bereits nur searchLower, das
|
||||
// schon in der Liste steht; verschoben wird nur, WO die Funktion steht.
|
||||
const { filteredModules, filteredGroups, noMatch } = useMemo(() => {
|
||||
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
|
||||
|
||||
if (searchLower === '') {
|
||||
return { filteredModules: modules, filteredGroups: groups, noMatch: false };
|
||||
}
|
||||
@@ -158,7 +163,6 @@ export default function AdminModuleGrantsPage() {
|
||||
return { filteredModules: modules, filteredGroups: groupHits, noMatch: false };
|
||||
}
|
||||
return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modules, groups, searchLower]);
|
||||
|
||||
const groupedModules = useMemo(() => {
|
||||
|
||||
@@ -78,6 +78,10 @@ export function InvoiceHistoryTable({
|
||||
}
|
||||
}, [onItemsLoaded]);
|
||||
|
||||
// Befund 16 (quick-260921-gof): refreshKey ist der Auffrisch-Ausloeser der
|
||||
// Elternseite nach "Jetzt pruefen" — ohne ihn bliebe die Historie auf dem
|
||||
// alten Stand stehen.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey ist der Auffrisch-Ausloeser der Elternseite nach "Jetzt pruefen" - ohne ihn bliebe die Historie auf dem alten Stand.
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
}, [load, refreshKey]);
|
||||
|
||||
Reference in New Issue
Block a user