mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 22:58:04 +02:00
Rencontres passées : une fenêtre, plusieurs photos, compression automatique
Le backoffice n'avait qu'une carte étroite par archive : un récit détaillé se tapait dans trois lignes, les photos ne pouvaient être ajoutées qu'après publication, une par une, et un fichier de plus de 900 Ko était simplement refusé — « compressez-la d'abord ». - **Fenêtre d'édition** : titre, date, lieu, rencontre liée, récit (champ de 260 px, redimensionnable), participants et photos au même endroit, création comprise. Glisser-déposer multiple, réordonnancement, légendes, suppression. - **Compression dans le navigateur** : `src/lib/image-compress.ts` (pur, testé) porte seuils et paliers, `image-compress-dom.ts` le décodage (orientation EXIF comprise) et l'encodage. On ne descend en qualité que le nécessaire, une image déjà dans le budget n'est pas réencodée et la transparence n'est jamais aplatie sur du noir. `ImageField` et le dépôt de promotion en profitent aussi : plus aucun envoi refusé pour son poids. - **Actions** : `savePastMeeting` (création + mise à jour, refus renvoyés au lieu d'être levés, rencontre liée vérifiée), `addPastMeetingPhotos` (envois groupés sous la limite des server actions) et `savePastMeetingPhotos` (ordre, légendes, suppressions, protégée par un drapeau `syncPhotos`). - **La fenêtre attend le rafraîchissement avant de se refermer** : sans cela, rouvrir la fiche affichait l'état d'avant, la rencontre liée revenant à « Aucune » alors qu'elle avait bien été enregistrée. - **Site public** : cartes de hauteur fixe quel que soit le texte ou le nombre de photos, et fenêtre de lecture avec le récit complet (sauts de ligne conservés) et la galerie entière — flèches, vignettes, clavier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GHFd8bXepDKhQ7kf3WvrL
This commit is contained in:
17 files changed
+1940
-291
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
BATCH_MAX_BYTES,
|
||||
MAX_SIDE,
|
||||
MIN_QUALITY,
|
||||
base64Bytes,
|
||||
chunkByBytes,
|
||||
compressionSteps,
|
||||
compressionSummary,
|
||||
isImageDataUri,
|
||||
needsCompression,
|
||||
scaleToFit,
|
||||
} from "../src/lib/image-compress";
|
||||
|
||||
describe("Mise à l'échelle", () => {
|
||||
it("ramène le côté le plus long sous la limite en gardant le rapport", () => {
|
||||
assert.deepEqual(scaleToFit(4032, 3024, 2048), { width: 2048, height: 1536 });
|
||||
assert.deepEqual(scaleToFit(3024, 4032, 2048), { width: 1536, height: 2048 });
|
||||
});
|
||||
|
||||
it("n'agrandit jamais une image plus petite que la limite", () => {
|
||||
assert.deepEqual(scaleToFit(800, 600, 2048), { width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("ne rend rien pour des dimensions absurdes", () => {
|
||||
assert.deepEqual(scaleToFit(0, 600, 2048), { width: 0, height: 0 });
|
||||
assert.deepEqual(scaleToFit(Number.NaN, 600, 2048), { width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paliers de compression", () => {
|
||||
const steps = compressionSteps(MAX_SIDE);
|
||||
|
||||
it("commence à la définition pleine et à la meilleure qualité", () => {
|
||||
assert.equal(steps[0].maxSide, MAX_SIDE);
|
||||
assert.ok(steps[0].quality >= 0.9);
|
||||
});
|
||||
|
||||
it("épuise la qualité avant de réduire la définition", () => {
|
||||
const firstDrop = steps.findIndex((step) => step.maxSide < MAX_SIDE);
|
||||
assert.ok(firstDrop > 1, "la définition ne doit pas tomber au deuxième essai");
|
||||
for (let index = 1; index < firstDrop; index += 1) {
|
||||
assert.ok(steps[index].quality < steps[index - 1].quality);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne remonte jamais en définition et reste au-dessus du plancher de qualité", () => {
|
||||
for (let index = 1; index < steps.length; index += 1) {
|
||||
assert.ok(steps[index].maxSide <= steps[index - 1].maxSide);
|
||||
}
|
||||
for (const step of steps) {
|
||||
assert.ok(step.quality >= MIN_QUALITY && step.quality <= 1);
|
||||
assert.ok(step.maxSide >= 320);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Poids d'une data-URI", () => {
|
||||
it("retrouve le poids décodé sans décoder", () => {
|
||||
const bytes = Buffer.from("x".repeat(1000));
|
||||
const dataUri = `data:image/jpeg;base64,${bytes.toString("base64")}`;
|
||||
assert.equal(base64Bytes(dataUri), 1000);
|
||||
});
|
||||
|
||||
it("tient compte du remplissage", () => {
|
||||
for (const length of [1, 2, 3, 4, 5]) {
|
||||
const raw = Buffer.alloc(length, 7);
|
||||
assert.equal(base64Bytes(`data:image/png;base64,${raw.toString("base64")}`), length);
|
||||
}
|
||||
});
|
||||
|
||||
it("rend zéro hors data-URI", () => {
|
||||
assert.equal(base64Bytes("https://exemple.fr/photo.jpg"), 0);
|
||||
assert.equal(base64Bytes(""), 0);
|
||||
});
|
||||
|
||||
it("ne reconnaît qu'une image en data-URI", () => {
|
||||
assert.ok(isImageDataUri("data:image/jpeg;base64,AAAA"));
|
||||
assert.ok(!isImageDataUri("https://exemple.fr/photo.jpg"));
|
||||
assert.ok(!isImageDataUri("data:text/html;base64,AAAA"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Décision de recompression", () => {
|
||||
it("garde le fichier d'origine quand il tient dans le budget et la définition", () => {
|
||||
assert.ok(!needsCompression({ bytes: 400_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image trop lourde", () => {
|
||||
assert.ok(needsCompression({ bytes: 4_000_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image légère mais démesurée", () => {
|
||||
assert.ok(needsCompression({ bytes: 200_000, width: 6000, height: 4000 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Résumé montré à l'utilisateur", () => {
|
||||
it("dit ce qui a été gagné", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 2048, height: 1536, bytes: 620_000, originalBytes: 4_200_000, recompressed: true }),
|
||||
"2048 × 1536 px · 4,2 Mo → 620 Ko · compressée automatiquement",
|
||||
);
|
||||
});
|
||||
|
||||
it("dit quand rien n'a été touché", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 1200, height: 900, bytes: 340_000, originalBytes: 340_000, recompressed: false }),
|
||||
"1200 × 900 px · 340 Ko · qualité d'origine conservée",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Envoi groupé", () => {
|
||||
it("regroupe les photos sous la limite d'une requête", () => {
|
||||
assert.deepEqual(chunkByBytes([700_000, 700_000, 700_000, 700_000], 2_000_000), [[0, 1], [2, 3]]);
|
||||
});
|
||||
|
||||
it("laisse partir seule une photo plus lourde que le budget", () => {
|
||||
assert.deepEqual(chunkByBytes([100_000, 3_000_000, 100_000], 2_000_000), [[0], [1], [2]]);
|
||||
});
|
||||
|
||||
it("rend un groupe vide pour une liste vide et couvre chaque index une fois", () => {
|
||||
assert.deepEqual(chunkByBytes([], BATCH_MAX_BYTES), []);
|
||||
const sizes = [500_000, 900_000, 200_000, 1_900_000, 50_000];
|
||||
const flat = chunkByBytes(sizes).flat();
|
||||
assert.deepEqual(flat, [0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Invariants de l'écran « Rencontres passées ». Ils portent sur du code que les
|
||||
* tests unitaires ne peuvent pas exécuter (composants client, actions serveur),
|
||||
* d'où la lecture des sources — même approche que `tests/security.test.ts`.
|
||||
*/
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
const actions = read("../src/app/backend/actions.ts");
|
||||
const dialog = read("../src/app/backend/rencontres-passees/PastMeetingDialog.tsx");
|
||||
const page = read("../src/app/backend/rencontres-passees/page.tsx");
|
||||
const imageField = read("../src/components/ImageField.tsx");
|
||||
const modal = read("../src/components/ModalShell.tsx");
|
||||
|
||||
test("aucune image n'est refusée pour son poids : elle est compressée", () => {
|
||||
const code = imageField.replace(/\/\/[^\n]*/g, "");
|
||||
assert.ok(!/\.size\s*>/.test(code), "ImageField refuse encore un fichier sur son poids");
|
||||
assert.ok(!/trop lourde/i.test(code), "ImageField renvoie encore « image trop lourde »");
|
||||
assert.ok(imageField.includes("prepareImageFile"), "ImageField ne compresse pas");
|
||||
assert.ok(dialog.includes("prepareImageFile"), "la fenêtre des rencontres ne compresse pas");
|
||||
});
|
||||
|
||||
test("les photos n'entrent en base qu'en data-URI", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function addPastMeetingPhotos"));
|
||||
const scope = body.slice(0, body.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(scope.includes("IMAGE_DATA_URI.test"), "une photo pourrait entrer sous forme d'URL (SSRF)");
|
||||
assert.ok(scope.includes("MAX_IMAGE_DATA_URI"), "aucune limite de taille côté serveur");
|
||||
});
|
||||
|
||||
test("la synchronisation des photos ne peut pas vider une galerie par accident", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(body.includes('formData.get("syncPhotos") !== "1"'), "le drapeau de garde a disparu");
|
||||
});
|
||||
|
||||
test("les refus d'enregistrement sont renvoyés, jamais levés", () => {
|
||||
const body = actions.slice(
|
||||
actions.indexOf("export async function savePastMeeting("),
|
||||
actions.indexOf("export async function deletePastMeeting("),
|
||||
);
|
||||
assert.ok(body.includes('return { error: "Donnez un titre'), "un titre vide ne rend plus d'erreur affichable");
|
||||
// Le seul `throw` admis est la violation d'accès, qui n'a pas à s'expliquer.
|
||||
const throws = [...body.matchAll(/throw new Error\("([^"]+)"\)/g)].map((match) => match[1]);
|
||||
assert.deepEqual(throws, ["Accès refusé"], `throw inattendu : ${throws.join(", ")}`);
|
||||
});
|
||||
|
||||
test("la rencontre liée est vérifiée avant l'écriture", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeeting("));
|
||||
assert.ok(body.includes("n'existe plus"), "une rencontre supprimée casserait l'enregistrement sans explication");
|
||||
});
|
||||
|
||||
test("la fenêtre ne se referme qu'une fois la page rafraîchie", () => {
|
||||
// Sans cette attente, rouvrir la fiche montrerait l'état d'avant
|
||||
// l'enregistrement : c'est ce qui fait croire qu'une rencontre liée « ne
|
||||
// veut pas » être retenue.
|
||||
assert.ok(dialog.includes("closing && !refreshing"), "la fermeture ne suit plus le rafraîchissement");
|
||||
});
|
||||
|
||||
test("la fenêtre est rendue dans un portail", () => {
|
||||
// Une carte survolée porte un `transform` : un `position: fixed` rendu à
|
||||
// l'intérieur s'ouvrirait dans la carte, pas sur la page.
|
||||
assert.ok(modal.includes("createPortal"), "la fenêtre n'est plus portée vers <body>");
|
||||
});
|
||||
|
||||
test("le récit s'écrit dans un grand champ, et la page ne le ré-affiche pas en entier", () => {
|
||||
assert.ok(/rows=\{1[0-9]\}/.test(dialog), "le champ de récit est redevenu minuscule");
|
||||
assert.ok(dialog.includes("minHeight: 260"), "la hauteur minimale du récit a disparu");
|
||||
assert.ok(page.includes("pm-card__text"), "la carte n'écrête plus le texte");
|
||||
});
|
||||
Reference in new issue
Block a user