From ec4e80c62c803ceaa39f148b5abc6d67cd5b91d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:12:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(logos):=20=E2=80=9EMehr=20laden=E2=80=9C=20?= =?UTF-8?q?beginnt=20von=20vorn,=20wenn=20der=20Cursor=20nicht=20mehr=20ex?= =?UTF-8?q?istiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schlussreview F2. Hat jemand das Logo gelöscht oder ausgeblendet, hinter dem „Mehr laden“ ansetzt (der Cursor), antwortet Appwrite mit 400 `general_cursor_not_found`. „Nochmal versuchen“ wiederholte dann denselben Cursor und scheiterte immer wieder. - Dieser Fehlertyp beim Nachladen löst jetzt `load('reset')` aus: die erste Seite der aktuellen Ansicht (gleiche Sortierung, gleicher Filter) wird neu geladen, die Kachel des gewählten Logos bleibt stehen. Kein Fehlertext, kein „Nochmal versuchen“ für den toten Cursor. - Jeder andere Fehler beim Nachladen und jeder Fehler beim Laden der ersten Seite bleibt wie bisher (Meldung und „Nochmal versuchen“); die erste Seite hat keinen Cursor, ein Neuladen im Kreis ist ausgeschlossen. - Tests mit einem Fehler {code: 400, type: 'general_cursor_not_found'}: Neuladen von Seite 1 mit Anfragefolge, Ansicht „Gemeldet“ bleibt, gewählte Kachel bleibt, Neuladen scheitert selbst, anderer Fehlertyp, Fehlertyp beim ersten Laden. Co-Authored-By: Claude Opus 5.5 --- web/src/logos/logo-picker.test.ts | 97 +++++++++++++++++++++++++++++++ web/src/logos/logo-picker.ts | 15 ++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index fe17911..93da882 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -460,6 +460,103 @@ describe('Mehr laden', () => { expect(alertText()).toBe(''); expect(retryButton().hidden).toBe(true); }); + + describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => { + /** Appwrite: 400 `general_cursor_not_found`. */ + const cursorGone = () => Object.assign(new Error('The current cursor could not be found.'), { code: 400, type: 'general_cursor_not_found' }); + + it('„Mehr laden“ beginnt von vorn: die erste Seite der Ansicht wird neu geladen, kein Fehler, kein „Nochmal versuchen“ mit dem toten Cursor', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); + await mount(); + fx.listLogos.mockRejectedValueOnce(cursorGone()); + fx.listLogos.mockResolvedValueOnce({ logos: [logo('c'), logo('b', { likeCount: 3 })], nextCursor: null }); + moreButton().click(); + await flush(); + // erste Anfrage: Seite 1, zweite: mit dem toten Cursor, dritte: wieder Seite 1 (ohne Cursor), gleiche Ansicht + expect(fx.listLogos.mock.calls.map(([opts]) => opts)).toEqual([ + { sort: 'popular', filter: 'all' }, + { sort: 'popular', filter: 'all', cursor: 'b' }, + { sort: 'popular', filter: 'all' }, + ]); + expect(names()).toEqual(['Kein Logo', 'Logo C', 'Logo B']); + expect(heart('Logo B').textContent).toBe('3'); // die frische Zeile gilt + expect(alertText()).toBe(''); + expect(retryButton().hidden).toBe(true); + expect(moreButton().hidden).toBe(true); // die neue Seite war die letzte + }); + + it('die Ansicht bleibt (hier die Admin-Ansicht „Gemeldet“): neu geladen wird mit derselben Sortierung und demselben Filter', async () => { + admin = true; + await mount(); + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + sortButton('Gemeldet').click(); + await flush(); + fx.listLogos.mockRejectedValueOnce(cursorGone()); + fx.listLogos.mockResolvedValueOnce({ logos: [logo('z')], nextCursor: 'z' }); + moreButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' }); + expect(names()).toEqual(['Kein Logo', 'Logo Z']); + expect(pressedSort()).toEqual(['Gemeldet']); + expect(moreButton().hidden).toBe(false); // und es geht mit dem neuen Cursor weiter + fx.listLogos.mockResolvedValueOnce({ logos: [logo('y')], nextCursor: null }); + moreButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported', cursor: 'z' }); + expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo Y']); + }); + + it('die Kachel des gewählten Logos bleibt stehen (kein Flackern), die übrigen werden ersetzt', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); + await mount(); + picker.select('a'); + const selected = tile('Logo A'); + fx.listLogos.mockRejectedValueOnce(cursorGone()); + fx.listLogos.mockResolvedValueOnce({ logos: [logo('a'), logo('c')], nextCursor: null }); + moreButton().click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); + expect(tile('Logo A')).toBe(selected); + expect(pressedTiles()).toEqual(['Logo A']); + }); + + it('scheitert das Neuladen selbst (Netz), steht der gewohnte Fehler mit „Nochmal versuchen“, das die erste Seite holt', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + await mount(); + fx.listLogos.mockRejectedValueOnce(cursorGone()); + fx.listLogos.mockRejectedValueOnce(new Error('offline')); + moreButton().click(); + await flush(); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + expect(retryButton().hidden).toBe(false); + fx.listLogos.mockResolvedValue({ logos: [logo('c')], nextCursor: null }); + retryButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); + expect(names()).toEqual(['Kein Logo', 'Logo C']); + expect(alertText()).toBe(''); + }); + + it('nur dieser Fehlertyp löst das Neuladen aus: jeder andere Fehler beim Nachladen bleibt ein Fehler mit „Nochmal versuchen“ für denselben Cursor', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + await mount(); + fx.listLogos.mockRejectedValueOnce(Object.assign(new Error('Bad request'), { code: 400, type: 'general_query_invalid' })); + moreButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenCalledTimes(2); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + expect(names()).toEqual(['Kein Logo', 'Logo A']); + expect(retryButton().hidden).toBe(false); + }); + + it('beim ersten Laden einer Ansicht gibt es keinen Cursor: derselbe Fehlertyp dort ist ein gewöhnlicher Ladefehler, kein Neuladen im Kreis', async () => { + fx.listLogos.mockRejectedValue(cursorGone()); + await mount(); + expect(fx.listLogos).toHaveBeenCalledTimes(1); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + expect(retryButton().hidden).toBe(false); + }); + }); }); describe('Auswahl', () => { diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index ff55683..a1cd088 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -43,6 +43,14 @@ export interface LogoPicker { const LOAD_ERROR = msg('Die Logos konnten nicht geladen werden.'); +/** + * Das Logo, hinter dem „Mehr laden“ ansetzt (der Cursor), gibt es nicht mehr, weil jemand es gelöscht oder ausgeblendet hat: Appwrite + * antwortet mit 400 `general_cursor_not_found`. Ein Wiederholen mit demselben Cursor scheitert dann immer wieder. + */ +function isCursorGone(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { type?: unknown }).type === 'general_cursor_not_found'; +} + /** * Die Ansichten des Umschalters. „Beliebt“ und „Neu“ sind öffentlich, „Gemeldet“ und „Ausgeblendet“ nur für Admins (der Server prüft * die Rechte; hier erscheinen die Knöpfe nur für sie). Die Admin-Ansichten zeigen die neuesten zuerst. @@ -337,9 +345,14 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } nextCursor = page.nextCursor; addLogos(page.logos); void loadHearts(page.logos.map((logo) => logo.id)); - } catch { + } catch (err) { if (disposed || token !== loadToken) return; loading = false; + if (mode === 'more' && isCursorGone(err)) { + // Die Liste hat sich unter dem Cursor verändert: statt eines Fehlers, den „Nochmal versuchen“ nicht beheben kann, von vorn laden. + void load('reset'); + return; + } failed = mode; statusEl.textContent = t(LOAD_ERROR); }