fix(community): Pixelgrenze beim Anzeigen, exakter Tag-Filter, unsichtbare Zeichen

- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über
  4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei).
  Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“
  Verspätet eintreffende URLs werden sofort freigegeben.
- Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“.
- Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM).
- Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 16:35:10 +00:00
1 parent 5500495443
commit c62075f2d4
12 files changed
+435 -66

No files matched your search

+36 -9
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import { ApiError, type ReportReason } from '../community/api';
import type { GalleryCard, GalleryReport } from '../community/gallery';
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery';
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
vi.mock('../account/appwrite', () => ({
@@ -43,6 +43,8 @@ const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
const blob = () => new Blob(['png'], { type: 'image/png' });
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` });
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
@@ -58,7 +60,7 @@ let urlCounter: number;
const revoked: string[] = [];
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
@@ -115,7 +117,7 @@ beforeEach(() => {
}
getCard.mockResolvedValue(card());
listReports.mockResolvedValue([]);
loadImage.mockImplementation(async () => blob());
loadImage.mockImplementation(async () => image());
withdrawCard.mockResolvedValue(undefined);
setVisibility.mockResolvedValue(undefined);
getUser.mockResolvedValue(null);
@@ -123,7 +125,6 @@ beforeEach(() => {
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
@@ -276,6 +277,31 @@ describe('Detailseite: Karte', () => {
expect(saveBlobAs).toHaveBeenCalledTimes(1);
});
it('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => {
// so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein <img> entsteht
loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.'));
await mount();
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
expect(q('.detail-card img')).toBeNull();
expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar
expect(buttonByText('Melden')).toBeDefined();
// auch Herunterladen bekommt das Bild nicht
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).not.toHaveBeenCalled();
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
});
it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => {
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
revoked.length = 0;
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).toHaveBeenCalledTimes(1);
expect(revoked).toEqual(['blob:detail-2']);
});
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
@@ -311,17 +337,18 @@ describe('Detailseite: Karte', () => {
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
// Bild kommt erst nach dem Verlassen
let finishImage!: (b: Blob) => void;
let finishImage!: (i: CheckedImage) => void;
getCard.mockResolvedValue(card());
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
unmount = mountCommunityPage(root, 'g1', deps());
await settle();
unmount();
unmount = null;
const created = urlCounter;
finishImage(blob());
revoked.length = 0;
finishImage({ blob: blob(), url: 'blob:detail-late' });
await settle();
expect(urlCounter).toBe(created);
expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben
expect(q('img')).toBeNull();
});
});
@@ -419,7 +446,7 @@ describe('Detailseite: Aufräumen', () => {
describe('Detailseite: Herunterladen und Vorlage', () => {
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
const full = blob();
loadImage.mockResolvedValue(full);
loadImage.mockImplementation(async () => image(full));
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
await mount();
buttonByText('Herunterladen')!.click();