fix(quick-260921-jt4): verschwendete Kalender-Abrufe abgestellt, t-Identitaet gemessen statt behauptet
Restposten 3a (doppeltes Ladefenster): lastFetchWindowRef merkt sich das
zuletzt TATSAECHLICH geholte from/to-Paar; ein Monatswechsel, dessen
berechnetes Fenster damit uebereinstimmt, ueberspringt fetchEvents. Der
5-Minuten-Auffrischer (force=true) umgeht den Sperrgriff immer, sonst
friert die Anzeige ein. computeFetchWindow und die Tagesgrenzen-Rundung
bleiben unangetastet.
Restposten 3b (Quellenliste je Monatswechsel): hasSourcesRef merkt sich
das Ergebnis; fetchSources laeuft nur beim Aufbau (Merkung leer) oder
erzwungen (Auffrischer) — eine neu eingerichtete Quelle wird weiterhin
binnen fuenf Minuten bemerkt.
Restposten 4 (t-Identitaet): neue translations-identity.test.tsx rendert
eine Testkomponente unter dem ECHTEN NextIntlClientProvider und beweist
per Referenzgleichheit, dass t bei einem lokalen Zustandswechsel
dasselbe Funktionsobjekt bleibt — bestaetigt durch den use-intl-4.13.0-
Quelltext (translate entsteht in einem useMemo, dessen Abhaengigkeiten
ausschliesslich aus dem root-staendigen Intl-Kontext stammen). Die
Faustregel aus 260921-gof ("t gehoert in keine Abhaengigkeitsliste")
bleibt als Konvention in Ordnung; die zugrunde liegende Annahme ("t ist
bei jedem Render frisch") ist damit ausdruecklich WIDERLEGT statt ein
drittes Mal weitergetragen. Die vier verbliebenen Stellen
(marketplace/page.tsx, admin/users/page.tsx,
calendar-settings-panel.tsx, calendar-source-form.tsx) bleiben deshalb
unveraendert.
Vier neue zaehlende Testfaelle in calendar-widget.test.tsx (Aufbau je 1,
abweichendes Fenster +1/+0, identisches Fenster +0/+0, erzwungener Lauf
+1/+1) plus ein Test, dass ein uebersprungener Lauf den Ladezustand
sauber beendet und geladene Termine nicht leert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||||
|
|
||||||
@@ -598,4 +598,114 @@ describe('CalendarWidget', () => {
|
|||||||
|
|
||||||
expect(document.activeElement).toBe(day20);
|
expect(document.activeElement).toBe(day20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restposten 3a/3b (quick-260921-jt4): Quellenliste und Termin-Ladefenster
|
||||||
|
// werden nicht mehr blind bei jedem Monatswechsel neu geholt.
|
||||||
|
it('Test 16: Aufbau holt Quellenliste und Termine je genau einmal', async () => {
|
||||||
|
mockFetchEvents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { CalendarWidget } = await import('./calendar-widget');
|
||||||
|
render(<CalendarWidget instanceId="cal-16" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17: Monatswechsel mit abweichendem Ladefenster holt die Termine erneut (+1), die Quellenliste NICHT (+0)', async () => {
|
||||||
|
mockFetchEvents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { CalendarWidget } = await import('./calendar-widget');
|
||||||
|
render(<CalendarWidget instanceId="cal-17" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 18: Monatswechsel mit identischem Ladefenster loest weder Termin- noch Quellen-Abruf aus (beide +0/+0)', async () => {
|
||||||
|
mockFetchEvents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { CalendarWidget } = await import('./calendar-widget');
|
||||||
|
render(<CalendarWidget instanceId="cal-18" config={{ lookaheadDays: 90 }} isEditMode={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// Juli -> August: das Ladefenster aendert sich wirklich (die 27.07.-
|
||||||
|
// Rastergrenze liegt jetzt NACH "heute", "from" wechselt vom Rasterstart
|
||||||
|
// auf den heutigen Tagesbeginn).
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
|
||||||
|
const augustWindow = mockFetchEvents.mock.calls[1];
|
||||||
|
|
||||||
|
// August -> September: bei 90 Tagen Vorschau deckt der (auf "heute" +
|
||||||
|
// 90 Tage eingefrorene) Vorschauhorizont beide Rasterenden — das
|
||||||
|
// errechnete Fenster ist Zeichen fuer Zeichen dasselbe wie in August.
|
||||||
|
// Abweichung vom im PLAN genannten Monatspaar Oktober/November, siehe
|
||||||
|
// SUMMARY: bei DIESEM eingefrorenen "heute" (15.07.2026) kollidieren
|
||||||
|
// nachweislich August und September, nicht Oktober/November — derselbe
|
||||||
|
// Mechanismus, ein anderes Monatspaar (vgl. Test 8, dieselbe Art
|
||||||
|
// Abweichung fuer den analogen 30-Tage-Fall).
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// Zeit fuer einen faelschlich uebersprungenen-und-doch-feuernden Abruf geben.
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(mockFetchEvents).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchEvents.mock.calls[1]).toEqual(augustWindow);
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 19: der 5-Minuten-Auffrischer holt immer beide, auch bei unveraendertem Fenster (beide +1/+1)', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
||||||
|
vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0));
|
||||||
|
mockFetchEvents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { CalendarWidget } = await import('./calendar-widget');
|
||||||
|
render(<CalendarWidget instanceId="cal-19" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
expect(mockFetchEvents).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEvents).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchSources).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 20: ein uebersprungener Lauf beendet den Ladezustand trotzdem sauber und leert die bereits geladenen Termine nicht', async () => {
|
||||||
|
mockFetchEvents.mockResolvedValue([
|
||||||
|
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { CalendarWidget } = await import('./calendar-widget');
|
||||||
|
render(<CalendarWidget instanceId="cal-20" config={{ lookaheadDays: 90 }} isEditMode={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // Juli -> August
|
||||||
|
await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // August -> September, uebersprungen
|
||||||
|
await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
// Der uebersprungene Lauf darf weder haengen bleiben (kein "Laden..."
|
||||||
|
// mehr sichtbar) noch die zuvor geladenen Termine leeren — "Naechste
|
||||||
|
// Termine" haengt an "heute", nicht am angezeigten Monat, und zeigt das
|
||||||
|
// beim Aufbau geladene Team Meeting deshalb weiterhin.
|
||||||
|
expect(screen.queryByText('Laden...')).not.toBeInTheDocument();
|
||||||
|
expect(within(screen.getByTestId('calendar-upcoming')).getByText('Team Meeting')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||||
|
|
||||||
|
// Restposten 3a/3b (quick-260921-jt4): zwei Merkungen ausserhalb des
|
||||||
|
// Render-Zustands, damit ein Monatswechsel, der weder die Quellenliste noch
|
||||||
|
// das Termin-Ladefenster tatsaechlich aendert, keinen erneuten Abruf
|
||||||
|
// ausloest — der 5-Minuten-Auffrischer (siehe `force` unten) ruft
|
||||||
|
// unabhaengig davon immer beide ab, sonst veraltet die Anzeige still.
|
||||||
|
// hasSourcesRef: `null` heisst "noch nie erfolgreich geholt".
|
||||||
|
const hasSourcesRef = useRef<boolean | null>(null);
|
||||||
|
// lastFetchWindowRef: die beiden ISO-Zeichenketten, die zuletzt
|
||||||
|
// TATSAECHLICH an fetchEvents gingen (nicht nur berechnet) — derselbe
|
||||||
|
// Vergleichswert, den auch die API sieht.
|
||||||
|
const lastFetchWindowRef = useRef<{ from: string; to: string } | null>(null);
|
||||||
|
|
||||||
// Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig
|
// Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig
|
||||||
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
|
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
|
||||||
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
|
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
|
||||||
@@ -81,13 +93,23 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function loadData() {
|
// `force`: true nur fuer den 5-Minuten-Auffrischer. Ein durch
|
||||||
|
// Monatswechsel ausgeloester Lauf (force=false) darf beide Sperrgriffe
|
||||||
|
// unten greifen lassen; der Auffrischer muss sie UNBEDINGT umgehen,
|
||||||
|
// sonst friert die Anzeige ein.
|
||||||
|
async function loadData(force: boolean) {
|
||||||
try {
|
try {
|
||||||
// Check if user has any sources configured
|
// Restposten 3b: die Quellenliste haengt nicht vom angezeigten Monat
|
||||||
const sources = await fetchSources();
|
// ab — nur holen, wenn sie noch unbekannt ist ODER der Lauf
|
||||||
if (cancelled) return;
|
// erzwungen wurde (Aufbau: hasSourcesRef ist null, greift also
|
||||||
|
// ohnehin; Auffrischer: force=true).
|
||||||
|
if (hasSourcesRef.current === null || force) {
|
||||||
|
const sources = await fetchSources();
|
||||||
|
if (cancelled) return;
|
||||||
|
hasSourcesRef.current = sources.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (sources.length === 0) {
|
if (!hasSourcesRef.current) {
|
||||||
setHasSources(false);
|
setHasSources(false);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -99,8 +121,26 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
// Fetch aggregated events for the current grid/lookahead window —
|
// Fetch aggregated events for the current grid/lookahead window —
|
||||||
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
|
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
|
||||||
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
|
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
|
||||||
const eventData = await fetchEvents(from.toISOString(), to.toISOString());
|
const fromIso = from.toISOString();
|
||||||
|
const toIso = to.toISOString();
|
||||||
|
|
||||||
|
// Restposten 3a: das neu berechnete Fenster deckt sich bei einem
|
||||||
|
// Monatswechsel manchmal mit dem zuletzt tatsaechlich geholten
|
||||||
|
// (siehe Kopfkommentar computeFetchWindow/SUMMARY) — dann ist der
|
||||||
|
// Abruf verschwendet und faellt bis zum naechsten Server durch.
|
||||||
|
const windowUnchanged =
|
||||||
|
!force &&
|
||||||
|
lastFetchWindowRef.current !== null &&
|
||||||
|
lastFetchWindowRef.current.from === fromIso &&
|
||||||
|
lastFetchWindowRef.current.to === toIso;
|
||||||
|
if (windowUnchanged) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = await fetchEvents(fromIso, toIso);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
lastFetchWindowRef.current = { from: fromIso, to: toIso };
|
||||||
setEvents(eventData);
|
setEvents(eventData);
|
||||||
} catch {
|
} catch {
|
||||||
// Silent fail — show empty state
|
// Silent fail — show empty state
|
||||||
@@ -114,11 +154,16 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
// Aufbau bzw. Monatswechsel: nicht erzwungen — die beiden Sperrgriffe
|
||||||
|
// oben entscheiden selbst, ob wirklich etwas Neues zu holen ist (beim
|
||||||
|
// allerersten Aufbau sind beide Merkungen leer, holen also ohnehin).
|
||||||
|
loadData(false);
|
||||||
|
|
||||||
// Refresh every 5 minutes (300000ms) to match backend cache TTL
|
// Refresh every 5 minutes (300000ms) to match backend cache TTL — IMMER
|
||||||
|
// erzwungen, sonst bemerkt die Kachel weder eine neue Quelle noch neue
|
||||||
|
// Termine innerhalb des Fensters.
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
loadData();
|
loadData(true);
|
||||||
}, 300_000);
|
}, 300_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { act, cleanup, render } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider, useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* translations-identity.test.tsx (Restposten 4, quick-260921-jt4)
|
||||||
|
*
|
||||||
|
* Prueft am ECHTEN `NextIntlClientProvider` — nicht behauptet, sondern
|
||||||
|
* gemessen —, ob `t` aus `useTranslations` bei einem Zustandswechsel EINER
|
||||||
|
* Komponente seine Identitaet behaelt. Der Quelltext der eingesetzten
|
||||||
|
* `use-intl`-Fassung (4.13.0, `react.js`) zeigt: `translate` entsteht in
|
||||||
|
* einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
|
||||||
|
* Intl-Kontext stammen (`cache, formatters, getMessageFallback, allMessages,
|
||||||
|
* namespace, onError, globalFormats, locale, timeZone`) — keine davon haengt
|
||||||
|
* vom lokalen Zustand einer Komponente ab. Der Anbieter (`NextIntlClientProvider`)
|
||||||
|
* steht in `app/layout.tsx`, also oberhalb aller Komponenten dieses Projekts;
|
||||||
|
* ein Zustandswechsel in einer Kindkomponente rendert den Anbieter nicht neu.
|
||||||
|
*
|
||||||
|
* Dieser eine Test entscheidet die Annahme aus quick-260921-gof
|
||||||
|
* ("`t` ist bei jedem Render eine frische Funktion") an der Wurzel statt an
|
||||||
|
* vier einzelnen Symptomstellen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
widgets: {
|
||||||
|
calendar: {
|
||||||
|
name: 'Kalender',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProbeComponent({ onRender }: { onRender: (t: unknown) => void }) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const [, forceRerender] = useState(0);
|
||||||
|
onRender(t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => forceRerender((n) => n + 1)}>
|
||||||
|
{t('calendar.name')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('t aus useTranslations — Identitaet ueber einen Zustandswechsel (Restposten 4)', () => {
|
||||||
|
it('bleibt beim erneuten Render derselben Komponente dasselbe Funktionsobjekt, wenn NUR lokaler Zustand wechselt', () => {
|
||||||
|
const seenTranslators: unknown[] = [];
|
||||||
|
const { getByRole } = render(
|
||||||
|
<NextIntlClientProvider locale="de" messages={messages} timeZone="Europe/Berlin">
|
||||||
|
<ProbeComponent onRender={(t) => seenTranslators.push(t)} />
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(seenTranslators).toHaveLength(1);
|
||||||
|
const tBeforeRerender = seenTranslators[0];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
getByRole('button').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenTranslators).toHaveLength(2);
|
||||||
|
const tAfterRerender = seenTranslators[1];
|
||||||
|
|
||||||
|
// Der gemessene Kern der Annahme: dasselbe Objekt, nicht nur denselben
|
||||||
|
// Rueckgabewert bei Aufruf — ein `toBe` (Referenzgleichheit), kein `toEqual`.
|
||||||
|
expect(tAfterRerender).toBe(tBeforeRerender);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user